diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000000..b0e38abdac --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "isRoot": true, + "tools": {} +} \ No newline at end of file diff --git a/Examples/UICatalog/Scenarios/AllViewsTester.cs b/Examples/UICatalog/Scenarios/AllViewsTester.cs index 2f6e9e0164..5ac97bea9b 100644 --- a/Examples/UICatalog/Scenarios/AllViewsTester.cs +++ b/Examples/UICatalog/Scenarios/AllViewsTester.cs @@ -65,7 +65,7 @@ public override void Main () // Dispose existing current View, if any DisposeCurrentView (); - CreateCurrentView (_viewClasses.Values.ToArray () [_classListView.SelectedItem]); + CreateCurrentView (_viewClasses.Values.ToArray () [_classListView.SelectedItem.Value]); // Force ViewToEdit to be the view and not a subview if (_adornmentsEditor is { }) diff --git a/Examples/UICatalog/Scenarios/ComboBoxIteration.cs b/Examples/UICatalog/Scenarios/ComboBoxIteration.cs index 9440f37f3f..0039263966 100644 --- a/Examples/UICatalog/Scenarios/ComboBoxIteration.cs +++ b/Examples/UICatalog/Scenarios/ComboBoxIteration.cs @@ -42,8 +42,8 @@ public override void Main () listview.SelectedItemChanged += (s, e) => { - lbListView.Text = items [e.Item]; - comboBox.SelectedItem = e.Item; + lbListView.Text = items [e.Item!.Value]; + comboBox.SelectedItem = e.Item.Value; }; comboBox.SelectedItemChanged += (sender, text) => diff --git a/Examples/UICatalog/Scenarios/DynamicMenuBar.cs b/Examples/UICatalog/Scenarios/DynamicMenuBar.cs index 2687b4f6cb..28a13a9169 100644 --- a/Examples/UICatalog/Scenarios/DynamicMenuBar.cs +++ b/Examples/UICatalog/Scenarios/DynamicMenuBar.cs @@ -712,7 +712,7 @@ public DynamicMenuBarSample () btnUp.Accepting += (s, e) => { - int i = _lstMenus.SelectedItem; + int i = _lstMenus.SelectedItem.Value; MenuItem menuItem = DataContext.Menus.Count > 0 ? DataContext.Menus [i].MenuItem : null; if (menuItem != null) @@ -734,7 +734,7 @@ public DynamicMenuBarSample () btnDown.Accepting += (s, e) => { - int i = _lstMenus.SelectedItem; + int i = _lstMenus.SelectedItem.Value; MenuItem menuItem = DataContext.Menus.Count > 0 ? DataContext.Menus [i].MenuItem : null; if (menuItem != null) @@ -836,7 +836,7 @@ public DynamicMenuBarSample () : MenuItemCheckStyle.Radio, ShortcutKey = frmMenuDetails.TextShortcutKey.Text }; - UpdateMenuItem (_currentEditMenuBarItem, menuItem, _lstMenus.SelectedItem); + UpdateMenuItem (_currentEditMenuBarItem, menuItem, _lstMenus.SelectedItem.Value); } }; @@ -885,8 +885,8 @@ public DynamicMenuBarSample () btnRemove.Accepting += (s, e) => { - MenuItem menuItem = (DataContext.Menus.Count > 0 && _lstMenus.SelectedItem > -1 - ? DataContext.Menus [_lstMenus.SelectedItem].MenuItem + MenuItem menuItem = (DataContext.Menus.Count > 0 && _lstMenus.SelectedItem is {} selectedItem + ? DataContext.Menus [selectedItem].MenuItem : _currentEditMenuBarItem); if (menuItem != null) @@ -905,9 +905,9 @@ public DynamicMenuBarSample () SelectCurrentMenuBarItem (); } - if (_lstMenus.SelectedItem > -1) + if (_lstMenus.SelectedItem is {} selected) { - DataContext.Menus?.RemoveAt (_lstMenus.SelectedItem); + DataContext.Menus?.RemoveAt (selected); } if (_lstMenus.Source.Count > 0 && _lstMenus.SelectedItem > _lstMenus.Source.Count - 1) @@ -927,7 +927,7 @@ public DynamicMenuBarSample () _lstMenus.OpenSelectedItem += (s, e) => { - _currentMenuBarItem = DataContext.Menus [e.Item].MenuItem; + _currentMenuBarItem = DataContext.Menus [e.Item.Value].MenuItem; if (!(_currentMenuBarItem is MenuBarItem)) { @@ -945,8 +945,8 @@ public DynamicMenuBarSample () _lstMenus.HasFocusChanging += (s, e) => { - MenuItem menuBarItem = _lstMenus.SelectedItem > -1 && DataContext.Menus.Count > 0 - ? DataContext.Menus [_lstMenus.SelectedItem].MenuItem + MenuItem menuBarItem = _lstMenus.SelectedItem is {} selectedItem && DataContext.Menus.Count > 0 + ? DataContext.Menus [selectedItem].MenuItem : null; SetFrameDetails (menuBarItem); }; @@ -1077,8 +1077,8 @@ void SetFrameDetails (MenuItem menuBarItem = null) if (menuBarItem == null) { - menuItem = _lstMenus.SelectedItem > -1 && DataContext.Menus.Count > 0 - ? DataContext.Menus [_lstMenus.SelectedItem].MenuItem + menuItem = _lstMenus.SelectedItem is {} selectedItem && DataContext.Menus.Count > 0 + ? DataContext.Menus [selectedItem].MenuItem : _currentEditMenuBarItem; } else diff --git a/Examples/UICatalog/Scenarios/DynamicStatusBar.cs b/Examples/UICatalog/Scenarios/DynamicStatusBar.cs index de110d0c6f..73dd3b802e 100644 --- a/Examples/UICatalog/Scenarios/DynamicStatusBar.cs +++ b/Examples/UICatalog/Scenarios/DynamicStatusBar.cs @@ -312,7 +312,12 @@ public DynamicStatusBarSample () btnUp.Accepting += (s, e) => { - int i = _lstItems.SelectedItem; + if (_lstItems.SelectedItem is null) + { + return; + } + int i = _lstItems.SelectedItem.Value; + Shortcut statusItem = DataContext.Items.Count > 0 ? DataContext.Items [i].Shortcut : null; if (statusItem != null) @@ -335,7 +340,12 @@ public DynamicStatusBarSample () btnDown.Accepting += (s, e) => { - int i = _lstItems.SelectedItem; + if (_lstItems.SelectedItem is null) + { + return; + } + int i = _lstItems.SelectedItem.Value; + Shortcut statusItem = DataContext.Items.Count > 0 ? DataContext.Items [i].Shortcut : null; if (statusItem != null) @@ -376,14 +386,17 @@ public DynamicStatusBarSample () } else if (_currentEditStatusItem != null) { - var statusItem = new DynamicStatusItem { Title = frmStatusBarDetails.TextTitle.Text, Action = frmStatusBarDetails.TextAction.Text, Shortcut = frmStatusBarDetails.TextShortcut.Text }; - UpdateStatusItem (_currentEditStatusItem, statusItem, _lstItems.SelectedItem); + + if (_lstItems.SelectedItem is { } selectedItem) + { + UpdateStatusItem (_currentEditStatusItem, statusItem, selectedItem); + } } }; @@ -420,14 +433,14 @@ public DynamicStatusBarSample () btnRemove.Accepting += (s, e) => { Shortcut statusItem = DataContext.Items.Count > 0 - ? DataContext.Items [_lstItems.SelectedItem].Shortcut + ? DataContext.Items [_lstItems.SelectedItem.Value].Shortcut : null; if (statusItem != null) { _statusBar.RemoveShortcut (_currentSelectedStatusBar); statusItem.Dispose (); - DataContext.Items.RemoveAt (_lstItems.SelectedItem); + DataContext.Items.RemoveAt (_lstItems.SelectedItem.Value); if (_lstItems.Source.Count > 0 && _lstItems.SelectedItem > _lstItems.Source.Count - 1) { @@ -442,7 +455,7 @@ public DynamicStatusBarSample () _lstItems.HasFocusChanging += (s, e) => { Shortcut statusItem = DataContext.Items.Count > 0 - ? DataContext.Items [_lstItems.SelectedItem].Shortcut + ? DataContext.Items [_lstItems.SelectedItem.Value].Shortcut : null; SetFrameDetails (statusItem); }; @@ -489,7 +502,7 @@ void SetFrameDetails (Shortcut statusItem = null) if (statusItem == null) { newStatusItem = DataContext.Items.Count > 0 - ? DataContext.Items [_lstItems.SelectedItem].Shortcut + ? DataContext.Items [_lstItems.SelectedItem.Value].Shortcut : null; } else diff --git a/Examples/UICatalog/Scenarios/ListViewWithSelection.cs b/Examples/UICatalog/Scenarios/ListViewWithSelection.cs index 874d061a58..a20eb67af7 100644 --- a/Examples/UICatalog/Scenarios/ListViewWithSelection.cs +++ b/Examples/UICatalog/Scenarios/ListViewWithSelection.cs @@ -237,7 +237,7 @@ public void Render ( int col, int line, int width, - int start = 0 + int viewportX = 0 ) { container.Move (col, line); @@ -247,7 +247,7 @@ public void Render ( string.Format ("{{0,{0}}}", -_nameColumnWidth), Scenarios [item].GetName () ); - RenderUstr (container, $"{s} ({Scenarios [item].GetDescription ()})", col, line, width, start); + RenderUstr (container, $"{s} ({Scenarios [item].GetDescription ()})", col, line, width, viewportX); } public void SetMark (int item, bool value) @@ -288,10 +288,10 @@ Scenarios [i].GetName () } // A slightly adapted method from: https://github.com/gui-cs/Terminal.Gui/blob/fc1faba7452ccbdf49028ac49f0c9f0f42bbae91/Terminal.Gui/Views/ListView.cs#L433-L461 - private void RenderUstr (View view, string ustr, int col, int line, int width, int start = 0) + private void RenderUstr (View view, string ustr, int col, int line, int width, int viewportX = 0) { var used = 0; - int index = start; + int index = viewportX; while (index < ustr.Length) { diff --git a/Examples/UICatalog/Scenarios/ListsAndCombos.cs b/Examples/UICatalog/Scenarios/ListsAndCombos.cs index dba1adc739..775c78d245 100644 --- a/Examples/UICatalog/Scenarios/ListsAndCombos.cs +++ b/Examples/UICatalog/Scenarios/ListsAndCombos.cs @@ -50,7 +50,7 @@ public override void Main () Width = Dim.Percent (40), Source = new ListWrapper (items) }; - listview.SelectedItemChanged += (s, e) => lbListView.Text = items [listview.SelectedItem]; + listview.SelectedItemChanged += (s, e) => lbListView.Text = items [listview.SelectedItem.Value]; win.Add (lbListView, listview); //var scrollBar = new ScrollBarView (listview, true); diff --git a/Examples/UICatalog/Scenarios/SpinnerStyles.cs b/Examples/UICatalog/Scenarios/SpinnerStyles.cs index e9e923ae7d..87e2b1b3b5 100644 --- a/Examples/UICatalog/Scenarios/SpinnerStyles.cs +++ b/Examples/UICatalog/Scenarios/SpinnerStyles.cs @@ -153,7 +153,7 @@ public override void Main () else { spinner.Visible = true; - spinner.Style = (SpinnerStyle)Activator.CreateInstance (styleDict [e.Item].Value); + spinner.Style = (SpinnerStyle)Activator.CreateInstance (styleDict [e.Item.Value].Value); delayField.Text = spinner.SpinDelay.ToString (); ckbBounce.CheckedState = spinner.SpinBounce ? CheckState.Checked : CheckState.UnChecked; ckbNoSpecial.CheckedState = !spinner.HasSpecialCharacters ? CheckState.Checked : CheckState.UnChecked; diff --git a/Examples/UICatalog/UICatalogTop.cs b/Examples/UICatalog/UICatalogTop.cs index 88a5e41443..63c3eb528b 100644 --- a/Examples/UICatalog/UICatalogTop.cs +++ b/Examples/UICatalog/UICatalogTop.cs @@ -43,7 +43,11 @@ public UICatalogTop () Unloaded += UnloadedHandler; // Restore previous selections - _categoryList.SelectedItem = _cachedCategoryIndex; + if (_categoryList.Source?.Count > 0) { + _categoryList.SelectedItem = _cachedCategoryIndex ?? 0; + } else { + _categoryList.SelectedItem = null; + } _scenarioList.SelectedRow = _cachedScenarioIndex; SchemeName = CachedTopLevelScheme = SchemeManager.SchemesToSchemeName (Schemes.Base); @@ -510,7 +514,7 @@ private void ScenarioView_OpenSelectedItem (object? sender, EventArgs? e) #region Category List private readonly ListView? _categoryList; - private static int _cachedCategoryIndex; + private static int? _cachedCategoryIndex; public static ObservableCollection? CachedCategories { get; set; } private ListView CreateCategoryList () @@ -540,7 +544,11 @@ private ListView CreateCategoryList () private void CategoryView_SelectedChanged (object? sender, ListViewItemEventArgs? e) { - string item = CachedCategories! [e!.Item]; + if (e is null or { Item: null }) + { + return; + } + string item = CachedCategories! [e.Item.Value]; ObservableCollection newScenarioList; if (e.Item == 0) diff --git a/Scripts/Run-LocalCoverage.ps1 b/Scripts/Run-LocalCoverage.ps1 index 312b229a96..32b88053d6 100644 --- a/Scripts/Run-LocalCoverage.ps1 +++ b/Scripts/Run-LocalCoverage.ps1 @@ -27,7 +27,7 @@ dotnet test Tests/UnitTests ` --verbosity minimal ` --collect:"XPlat Code Coverage" ` --settings Tests/UnitTests/runsettings.coverage.xml ` - --blame-hang-timeout 10s + --blame-hang-timeout 60s # ------------------------------------------------------------ # 4. Run UNIT TESTS (parallel) diff --git a/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs b/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs index 39234b10be..7fd71f4917 100644 --- a/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs +++ b/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs @@ -1,6 +1,5 @@ - namespace Terminal.Gui.Views; /// @@ -27,8 +26,13 @@ private set public int TypingDelay { get; set; } = 500; /// - public int GetNextMatchingItem (int currentIndex, char keyStruck) + public int? GetNextMatchingItem (int? currentIndex, char keyStruck) { + if (currentIndex.HasValue && currentIndex < 0) + { + throw new ArgumentOutOfRangeException (nameof (currentIndex), @"Must be non-negative"); + } + if (!char.IsControl (keyStruck)) { // maybe user pressed 'd' and now presses 'd' again. @@ -36,7 +40,7 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) // but if we find none then we must fallback on cycling // d instead and discard the candidate state var candidateState = ""; - var elapsedTime = DateTime.Now - _lastKeystroke; + TimeSpan elapsedTime = DateTime.Now - _lastKeystroke; Logging.Debug ($"CollectionNavigator began processing '{keyStruck}', it has been {elapsedTime} since last keystroke"); @@ -51,26 +55,28 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) { // its a fresh keystroke after some time // or its first ever key press - SearchString = new string (keyStruck, 1); - Logging.Debug ($"It has been too long since last key press so beginning new search"); + SearchString = new (keyStruck, 1); + Logging.Debug ("It has been too long since last key press so beginning new search"); } - int idxCandidate = GetNextMatchingItem ( - currentIndex, - candidateState, + int? idxCandidate = GetNextMatchingItem ( + currentIndex, + candidateState, - // prefer not to move if there are multiple characters e.g. "ca" + 'r' should stay on "car" and not jump to "cart" - candidateState.Length > 1 - ); + // prefer not to move if there are multiple characters e.g. "ca" + 'r' should stay on "car" and not jump to "cart" + candidateState.Length > 1 + ); Logging.Debug ($"CollectionNavigator searching (preferring minimum movement) matched:{idxCandidate}"); - if (idxCandidate != -1) + + if (idxCandidate is { }) { // found "dd" so candidate search string is accepted _lastKeystroke = DateTime.Now; SearchString = candidateState; Logging.Debug ($"Found collection item that matched search:{idxCandidate}"); + return idxCandidate; } @@ -83,16 +89,17 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) // if a match wasn't found, the user typed a 'wrong' key in their search ("can" + 'z' // instead of "can" + 'd'). - if (SearchString.Length > 1 && idxCandidate == -1) + if (SearchString.Length > 1 && idxCandidate is null) { Logging.Debug ("CollectionNavigator ignored key and returned existing index"); + // ignore it since we're still within the typing delay // don't add it to SearchString either return currentIndex; } // if no changes to current state manifested - if (idxCandidate == currentIndex || idxCandidate == -1) + if (idxCandidate == currentIndex || idxCandidate is null) { Logging.Debug ("CollectionNavigator found no changes to current index, so clearing search"); @@ -100,37 +107,29 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) ClearSearchString (); // match on the fresh letter alone - SearchString = new string (keyStruck, 1); + SearchString = new (keyStruck, 1); idxCandidate = GetNextMatchingItem (currentIndex, SearchString); Logging.Debug ($"CollectionNavigator new SearchString {SearchString} matched index:{idxCandidate}"); - return idxCandidate == -1 ? currentIndex : idxCandidate; + return idxCandidate ?? currentIndex; } Logging.Debug ($"CollectionNavigator final answer was:{idxCandidate}"); + // Found another "d" or just leave index as it was return idxCandidate; } - Logging.Debug ("CollectionNavigator found key press was not actionable so clearing search and returning -1"); + Logging.Debug ("CollectionNavigator found key press was not actionable so clearing search and returning null"); // clear state because keypress was a control char ClearSearchString (); // control char indicates no selection - return -1; + return null; } - - - /// - /// Raised when the is changed. Useful for debugging. Raises the - /// event. - /// - /// - protected virtual void OnSearchStringChanged (KeystrokeNavigatorEventArgs e) { SearchStringChanged?.Invoke (this, e); } - /// This event is raised when is changed. Useful for debugging. public event EventHandler? SearchStringChanged; @@ -141,6 +140,13 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) /// Return the number of elements in the collection protected abstract int GetCollectionLength (); + /// + /// Raised when the is changed. Useful for debugging. Raises the + /// event. + /// + /// + protected virtual void OnSearchStringChanged (KeystrokeNavigatorEventArgs e) { SearchStringChanged?.Invoke (this, e); } + /// Gets the index of the next item in the collection that matches . /// The index in the collection to start the search from. /// The search string to use. @@ -150,17 +156,17 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) /// (the default), the next matching item will be returned, even if it is above in the /// collection. /// - /// The index of the next matching item or if no match was found. - internal int GetNextMatchingItem (int currentIndex, string search, bool minimizeMovement = false) + /// The index of the next matching item or if no match was found. + internal int? GetNextMatchingItem (int? currentIndex, string search, bool minimizeMovement = false) { if (string.IsNullOrEmpty (search)) { - return -1; + return null; } int collectionLength = GetCollectionLength (); - if (currentIndex != -1 && currentIndex < collectionLength && Matcher.IsMatch (search, ElementAt (currentIndex))) + if (currentIndex.HasValue && currentIndex < collectionLength && Matcher.IsMatch (search, ElementAt (currentIndex.Value))) { // we are already at a match if (minimizeMovement) @@ -172,9 +178,9 @@ internal int GetNextMatchingItem (int currentIndex, string search, bool minimize for (var i = 1; i < collectionLength; i++) { //circular - int idxCandidate = (i + currentIndex) % collectionLength; + int? idxCandidate = (i + currentIndex) % collectionLength; - if (Matcher.IsMatch (search, ElementAt (idxCandidate))) + if (Matcher.IsMatch (search, ElementAt (idxCandidate!.Value))) { return idxCandidate; } @@ -194,7 +200,7 @@ internal int GetNextMatchingItem (int currentIndex, string search, bool minimize } // Nothing matches - return -1; + return null; } private void ClearSearchString () diff --git a/Terminal.Gui/Views/CollectionNavigation/ICollectionNavigator.cs b/Terminal.Gui/Views/CollectionNavigation/ICollectionNavigator.cs index 5ea6216463..69256db434 100644 --- a/Terminal.Gui/Views/CollectionNavigation/ICollectionNavigator.cs +++ b/Terminal.Gui/Views/CollectionNavigation/ICollectionNavigator.cs @@ -6,7 +6,7 @@ namespace Terminal.Gui.Views; /// /// Navigates a collection of items using keystrokes. The keystrokes are used to build a search string. The /// is used to find the next item in the collection that matches the search string when -/// is called. +/// is called. /// /// If the user types keystrokes that can't be found in the collection, the search string is cleared and the next /// item is found that starts with the last keystroke. @@ -17,7 +17,7 @@ public interface ICollectionNavigator { /// /// Gets or sets the number of milliseconds to delay before clearing the search string. The delay is reset on each - /// call to . The default is 500ms. + /// call to . The default is 500ms. /// public int TypingDelay { get; set; } @@ -43,8 +43,8 @@ public interface ICollectionNavigator /// The index in the collection to start the search from. /// The character of the key the user pressed. /// - /// The index of the item that matches what the user has typed. Returns if no item in the + /// The index of the item that matches what the user has typed. Returns if no item in the /// collection matched. /// - int GetNextMatchingItem (int currentIndex, char keyStruck); + int? GetNextMatchingItem (int? currentIndex, char keyStruck); } diff --git a/Terminal.Gui/Views/ComboBox.cs b/Terminal.Gui/Views/ComboBox.cs index 86c4f4f796..83e72ac79a 100644 --- a/Terminal.Gui/Views/ComboBox.cs +++ b/Terminal.Gui/Views/ComboBox.cs @@ -47,9 +47,9 @@ public ComboBox () }; _listview.SelectedItemChanged += (sender, e) => { - if (!HideDropdownListOnClick && _searchSet.Count > 0) + if (e.Item >= 0 && !HideDropdownListOnClick && _searchSet.Count > 0) { - SetValue (_searchSet [_listview.SelectedItem]); + SetValue (_searchSet [e.Item.Value]); } }; Add (_search, _listview); @@ -114,7 +114,7 @@ public ComboBox () /// protected override bool OnSettingScheme (ValueChangingEventArgs args) { - _listview.SetScheme(args.NewValue); + _listview.SetScheme (args.NewValue); return base.OnSettingScheme (args); } @@ -461,7 +461,10 @@ private bool ExpandCollapse () private void FocusSelectedItem () { - _listview.SelectedItem = SelectedItem > -1 ? SelectedItem : 0; + if (_listview.Source?.Count > 0) + { + _listview.SelectedItem = SelectedItem > -1 ? SelectedItem : 0; + } _listview.TabStop = TabBehavior.TabStop; _listview.SetFocus (); OnExpanded (); @@ -517,9 +520,9 @@ private void HideList () _listview.TabStop = TabBehavior.TabStop; _listview.SetFocus (); - if (_listview.SelectedItem > -1) + if (_listview.SelectedItem is { }) { - SetValue (_searchSet [_listview.SelectedItem]); + SetValue (_searchSet [_listview.SelectedItem.Value]); } else { @@ -728,7 +731,7 @@ private bool SelectText () IsShow = false; _listview.TabStop = TabBehavior.NoStop; - if (_listview.Source.Count == 0 || (_searchSet?.Count ?? 0) == 0) + if (_listview.Source!.Count == 0 || (_searchSet?.Count ?? 0) == 0) { _text = ""; HideList (); @@ -737,7 +740,7 @@ private bool SelectText () return false; } - SetValue (_listview.SelectedItem > -1 ? _searchSet [_listview.SelectedItem] : _text); + SetValue (_listview.SelectedItem is { } ? _searchSet [_listview.SelectedItem.Value] : _text); _search.CursorPosition = _search.Text.GetColumns (); ShowHideList (Text); OnOpenSelectedItem (); @@ -977,7 +980,11 @@ public override bool OnSelectedChanged () { bool res = base.OnSelectedChanged (); - _highlighted = SelectedItem; + if (SelectedItem is null) + { + return res; + } + _highlighted = SelectedItem.Value; return res; } @@ -997,7 +1004,7 @@ private void SetInitialProperties (ComboBox container, bool hideDropdownListOnCl _container = container ?? throw new ArgumentNullException ( nameof (container), - "ComboBox container cannot be null." + @"ComboBox container cannot be null." ); HideDropdownListOnClick = hideDropdownListOnClick; AddCommand (Command.Up, () => _container.MoveUpList ()); diff --git a/Terminal.Gui/Views/IListDataSource.cs b/Terminal.Gui/Views/IListDataSource.cs index 76ab7e9568..d5d1e5bded 100644 --- a/Terminal.Gui/Views/IListDataSource.cs +++ b/Terminal.Gui/Views/IListDataSource.cs @@ -4,43 +4,68 @@ namespace Terminal.Gui.Views; -/// Implement to provide custom rendering for a . +/// +/// Provides data and rendering for . Implement this interface to provide custom rendering +/// or to wrap custom data sources. +/// +/// +/// +/// The default implementation is which renders items using +/// . +/// +/// +/// Implementors must manage their own marking state and raise when the +/// underlying data changes. +/// +/// public interface IListDataSource : IDisposable { /// - /// Event to raise when an item is added, removed, or moved, or the entire list is refreshed. + /// Raised when items are added, removed, moved, or the entire collection is refreshed. /// + /// + /// subscribes to this event to update its display and content size when the data + /// changes. Implementations should raise this event whenever the underlying collection changes, unless + /// is . + /// event NotifyCollectionChangedEventHandler CollectionChanged; - /// Returns the number of elements to display + /// Gets the number of items in the data source. int Count { get; } - /// Returns the maximum length of elements to display - int Length { get; } - - /// - /// Allow suspending the event from being invoked, - /// if , otherwise is . - /// - bool SuspendCollectionChangedEvent { get; set; } - - /// Should return whether the specified item is currently marked. - /// , if marked, otherwise. - /// Item index. + /// Determines whether the specified item is marked. + /// The zero-based index of the item. + /// if the item is marked; otherwise . + /// + /// calls this method to determine whether to render the item with a mark indicator when + /// is . + /// bool IsMarked (int item); - /// This method is invoked to render a specified item, the method should cover the entire provided width. - /// The render. - /// The list view 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. + /// Gets the width in columns of the widest item in the data source. + /// + /// uses this value to set its horizontal content size for scrolling. + /// + int Length { get; } + + /// Renders the specified item to the . + /// The to render to. + /// + /// if the item is currently selected; otherwise . + /// + /// The zero-based index of the item to render. + /// The column in where rendering starts. + /// The line in where rendering occurs. + /// The width available for rendering. + /// The horizontal scroll offset. /// - /// The default color will be set before this method is invoked, and will be based on whether the item is selected - /// or not. + /// + /// calls this method for each visible item during rendering. The color scheme will be + /// set based on selection state before this method is called. + /// + /// + /// Implementations must fill the entire to avoid rendering artifacts. + /// /// void Render ( ListView listView, @@ -49,15 +74,33 @@ void Render ( int col, int line, int width, - int start = 0 + int viewportX = 0 ); - /// Flags the item as marked. - /// Item index. - /// If set to value. + /// Sets the marked state of the specified item. + /// The zero-based index of the item. + /// to mark the item; to unmark it. + /// + /// calls this method when the user toggles marking (e.g., via the SPACE key) if + /// is . + /// void SetMark (int item, bool value); - /// Return the source as IList. - /// + /// + /// Gets or sets whether the event should be suppressed. + /// + /// + /// Set to to prevent from being raised during bulk + /// operations. Set back to to resume event notifications. + /// + bool SuspendCollectionChangedEvent { get; set; } + + /// Returns the underlying data source as an . + /// The data source as an . + /// + /// uses this method to access individual items for events like + /// and to enable keyboard search via + /// . + /// IList ToList (); } diff --git a/Terminal.Gui/Views/ListView.cs b/Terminal.Gui/Views/ListView.cs index 729e8066bc..e958844bfe 100644 --- a/Terminal.Gui/Views/ListView.cs +++ b/Terminal.Gui/Views/ListView.cs @@ -1,4 +1,4 @@ -#nullable disable +#nullable enable using System.Collections; using System.Collections.ObjectModel; using System.Collections.Specialized; @@ -17,7 +17,8 @@ namespace Terminal.Gui.Views; /// /// /// By default uses to render the items of any -/// object (e.g. arrays, , and other collections). Alternatively, an +/// object (e.g. arrays, , and other collections). +/// Alternatively, an /// object that implements can be provided giving full control of what is rendered. /// /// @@ -43,11 +44,6 @@ namespace Terminal.Gui.Views; /// public class ListView : View, IDesignable { - private bool _allowsMarking; - private bool _allowsMultipleSelection = false; - private int _lastSelectedItem = -1; - private int _selected = -1; - private IListDataSource _source; // 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. @@ -63,22 +59,8 @@ public ListView () // Things this view knows how to do // - AddCommand (Command.Up, (ctx) => - { - if (RaiseSelecting (ctx) == true) - { - return true; - } - return MoveUp (); - }); - AddCommand (Command.Down, (ctx) => - { - if (RaiseSelecting (ctx) == true) - { - return true; - } - return MoveDown (); - }); + AddCommand (Command.Up, ctx => RaiseSelecting (ctx) == true || MoveUp ()); + AddCommand (Command.Down, ctx => RaiseSelecting (ctx) == true || MoveDown ()); // TODO: add RaiseSelecting to all of these AddCommand (Command.ScrollUp, () => ScrollVertical (-1)); @@ -91,66 +73,67 @@ public ListView () 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; - } - - if (OnOpenSelectedItem ()) - { - return true; - } + AddCommand ( + Command.Accept, + ctx => + { + if (RaiseAccepting (ctx) == true) + { + return true; + } - return false; - }); + return OnOpenSelectedItem (); + }); // Select (Space key and single-click) - If markable, change mark and raise Select event - AddCommand (Command.Select, (ctx) => - { - if (_allowsMarking) - { - if (RaiseSelecting (ctx) == true) - { - return true; - } - - if (MarkUnmarkSelectedItem ()) - { - return true; - } - } + AddCommand ( + Command.Select, + ctx => + { + if (!_allowsMarking) + { + return false; + } - return false; - }); + if (RaiseSelecting (ctx) == true) + { + return true; + } + return MarkUnmarkSelectedItem (); + }); // Hotkey - If none set, select and raise Select event. SetFocus. - DO NOT raise Accept - AddCommand (Command.HotKey, (ctx) => - { - if (SelectedItem == -1) - { - SelectedItem = 0; - if (RaiseSelecting (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); - }); + AddCommand ( + Command.HotKey, + ctx => + { + if (SelectedItem is { }) + { + return !SetFocus (); + } + + SelectedItem = 0; + + if (RaiseSelecting (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); @@ -169,23 +152,25 @@ public ListView () KeyBindings.Add (Key.End, Command.End); // Key.Space is already bound to Command.Select; this gives us select then move down - KeyBindings.Add (Key.Space.WithShift, [Command.Select, Command.Down]); + KeyBindings.Add (Key.Space.WithShift, Command.Select, 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)); } - /// - protected override void OnViewportChanged (DrawEventArgs e) - { - SetContentSize (new Size (MaxLength, _source?.Count ?? Viewport.Height)); - } + private bool _allowsMarking; + private bool _allowsMultipleSelection; - /// - protected override void OnFrameChanged (in Rectangle frame) + private IListDataSource? _source; + + /// + public bool EnableForDesign () { - EnsureSelectedItemVisible (); + 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. @@ -217,10 +202,10 @@ public bool AllowsMultipleSelection if (Source is { } && !_allowsMultipleSelection) { - // Clear all selections except selected + // Clear all selections except selected for (var i = 0; i < Source.Count; i++) { - if (Source.IsMarked (i) && i != _selected) + if (Source.IsMarked (i) && SelectedItem.HasValue && i != SelectedItem.Value) { Source.SetMark (i, false); } @@ -231,11 +216,34 @@ public bool AllowsMultipleSelection } } + /// + /// 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(); + public IListCollectionNavigator KeystrokeNavigator { get; } = new CollectionNavigator (); /// Gets or sets the leftmost column that is currently visible (when scrolling horizontally). /// The left position. @@ -244,7 +252,7 @@ public int LeftItem get => Viewport.X; set { - if (_source is null) + if (Source is null) { return; } @@ -259,99 +267,6 @@ public int LeftItem } } - /// 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 is null || _source.Count == 0) - { - return; - } - - if (value < -1 || value >= _source.Count) - { - throw new ArgumentException ("value"); - } - - _selected = value; - OnSelectedChanged (); - } - } - - /// 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)); - if (IsInitialized) - { - // Viewport = Viewport with { Y = 0 }; - } - - KeystrokeNavigator.Collection = _source?.ToList (); - _selected = -1; - _lastSelectedItem = -1; - SetNeedsDraw (); - } - } - - - private void Source_CollectionChanged (object sender, NotifyCollectionChangedEventArgs e) - { - SetContentSize (new Size (_source?.Length ?? Viewport.Width, _source?.Count ?? Viewport.Width)); - - if (Source is { Count: > 0 } && _selected > Source.Count - 1) - { - SelectedItem = Source.Count - 1; - } - - SetNeedsDraw (); - - OnCollectionChanged (e); - } - - /// 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 , /// marks all items. @@ -367,205 +282,68 @@ public bool MarkAll (bool mark) if (AllowsMultipleSelection) { - for (var i = 0; i < Source.Count; i++) + for (var i = 0; i < Source?.Count; i++) { Source.SetMark (i, mark); } + return true; } return false; } - /// - /// 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 != _selected) - { - Source.SetMark (i, false); - - return true; - } - } - } - - return true; - } - - /// Ensures the selected item is always visible on the screen. - public void EnsureSelectedItemVisible () - { - if (_selected == -1) - { - return; - } - if (_selected < Viewport.Y) - { - Viewport = Viewport with { Y = _selected }; - } - else if (Viewport.Height > 0 && _selected >= Viewport.Y + Viewport.Height) - { - Viewport = Viewport with { Y = _selected - Viewport.Height + 1 }; - } - } - /// Marks the if it is not already marked. /// if the was marked. public bool MarkUnmarkSelectedItem () { - if (UnmarkAllButSelected ()) - { - Source.SetMark (SelectedItem, !Source.IsMarked (SelectedItem)); - SetNeedsDraw (); - - return Source.IsMarked (SelectedItem); - } - - // BUGBUG: Shouldn't this return Source.IsMarked (SelectedItem) - - 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) + if (Source is null || SelectedItem is null || !UnmarkAllButSelected ()) { 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; - } - - _selected = Viewport.Y + me.Position.Y; - - if (MarkUnmarkSelectedItem ()) - { - // return true; - } - - OnSelectedChanged (); + Source.SetMark (SelectedItem.Value, !Source.IsMarked (SelectedItem.Value)); SetNeedsDraw (); - if (me.Flags == MouseFlags.Button1DoubleClicked) - { - return InvokeCommand (Command.Accept) is true; - } - - return true; + 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) + if (Source is null || Source.Count == 0) { - // Do we set lastSelectedItem to -1 here? return false; //Nothing for us to move to } - if (_selected >= _source.Count) + if (SelectedItem is null || SelectedItem >= Source.Count) { - // If for some reason we are currently outside of the - // valid values range, we should select the bottommost valid value. + // 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. - _selected = _source.Count - 1; - OnSelectedChanged (); - SetNeedsDraw (); + SelectedItem = SelectedItem is null ? 0 : Source.Count - 1; } - else if (_selected + 1 < _source.Count) + else if (SelectedItem + 1 < Source.Count) { //can move by down by one. - _selected++; + SelectedItem++; - if (_selected >= Viewport.Y + Viewport.Height) + if (SelectedItem >= Viewport.Y + Viewport.Height) { Viewport = Viewport with { Y = Viewport.Y + 1 }; } - else if (_selected < Viewport.Y) + else if (SelectedItem < Viewport.Y) { - Viewport = Viewport with { Y = _selected }; + Viewport = Viewport with { Y = SelectedItem.Value }; } - - OnSelectedChanged (); - SetNeedsDraw (); - } - else if (_selected == 0) - { - OnSelectedChanged (); - SetNeedsDraw (); } - else if (_selected >= Viewport.Y + Viewport.Height) + else if (SelectedItem >= Viewport.Y + Viewport.Height) { - Viewport = Viewport with { Y = _source.Count - Viewport.Height }; - SetNeedsDraw (); + Viewport = Viewport with { Y = Source.Count - Viewport.Height }; } return true; @@ -575,22 +353,19 @@ public virtual bool MoveDown () /// public virtual bool MoveEnd () { - if (_source is { Count: > 0 } && _selected != _source.Count - 1) + if (Source is { Count: > 0 } && SelectedItem != Source.Count - 1) { - _selected = _source.Count - 1; + SelectedItem = Source.Count - 1; - if (Viewport.Y + _selected > Viewport.Height - 1) + if (Viewport.Y + SelectedItem > Viewport.Height - 1) { Viewport = Viewport with { - Y = _selected < Viewport.Height - 1 - ? Math.Max (Viewport.Height - _selected + 1, 0) - : Math.Max (_selected - Viewport.Height + 1, 0) + Y = SelectedItem < Viewport.Height - 1 + ? Math.Max (Viewport.Height - SelectedItem.Value + 1, 0) + : Math.Max (SelectedItem.Value - Viewport.Height + 1, 0) }; } - - OnSelectedChanged (); - SetNeedsDraw (); } return true; @@ -600,12 +375,10 @@ public virtual bool MoveEnd () /// public virtual bool MoveHome () { - if (_selected != 0) + if (SelectedItem != 0) { - _selected = 0; - Viewport = Viewport with { Y = _selected }; - OnSelectedChanged (); - SetNeedsDraw (); + SelectedItem = 0; + Viewport = Viewport with { Y = SelectedItem.Value }; } return true; @@ -618,33 +391,30 @@ public virtual bool MoveHome () /// public virtual bool MovePageDown () { - if (_source is null) + if (Source is null || Source.Count == 0) { - return true; + return false; } - int n = _selected + Viewport.Height; + int n = (SelectedItem ?? 0) + Viewport.Height; - if (n >= _source.Count) + if (n >= Source.Count) { - n = _source.Count - 1; + n = Source.Count - 1; } - if (n != _selected) + if (n != SelectedItem) { - _selected = n; + SelectedItem = n; - if (_source.Count >= Viewport.Height) + if (Source.Count >= Viewport.Height) { - Viewport = Viewport with { Y = _selected }; + Viewport = Viewport with { Y = SelectedItem.Value }; } else { Viewport = Viewport with { Y = 0 }; } - - OnSelectedChanged (); - SetNeedsDraw (); } return true; @@ -654,19 +424,22 @@ public virtual bool MovePageDown () /// public virtual bool MovePageUp () { - int n = _selected - Viewport.Height; - - if (n < 0) + if (Source is null || Source.Count == 0) + { + return false; + } + + int n = (SelectedItem ?? 0) - Viewport.Height; + + if (n < 0) { n = 0; } - if (n != _selected) + if (n != SelectedItem && n < Source?.Count) { - _selected = n; - Viewport = Viewport with { Y = _selected }; - OnSelectedChanged (); - SetNeedsDraw (); + SelectedItem = n; + Viewport = Viewport with { Y = SelectedItem.Value }; } return true; @@ -676,178 +449,119 @@ public virtual bool MovePageUp () /// public virtual bool MoveUp () { - if (_source is null || _source.Count == 0) + if (Source is null || Source.Count == 0) { - // Do we set lastSelectedItem to -1 here? return false; //Nothing for us to move to } - if (_selected >= _source.Count) + if (SelectedItem is null || SelectedItem >= Source.Count) { - // If for some reason we are currently outside of the + // 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. - _selected = _source.Count - 1; - OnSelectedChanged (); - SetNeedsDraw (); + SelectedItem = Source.Count - 1; } - else if (_selected > 0) + else if (SelectedItem > 0) { - _selected--; + SelectedItem--; - if (_selected > Source.Count) + if (SelectedItem > Source.Count) { - _selected = Source.Count - 1; + SelectedItem = Source.Count - 1; } - if (_selected < Viewport.Y) + if (SelectedItem < Viewport.Y) { - Viewport = Viewport with { Y = _selected }; + Viewport = Viewport with { Y = SelectedItem.Value }; } - else if (_selected > Viewport.Y + Viewport.Height) + else if (SelectedItem > Viewport.Y + Viewport.Height) { - Viewport = Viewport with { Y = _selected - Viewport.Height + 1 }; + Viewport = Viewport with { Y = SelectedItem.Value - Viewport.Height + 1 }; } - - OnSelectedChanged (); - SetNeedsDraw (); } - else if (_selected < Viewport.Y) + else if (SelectedItem < Viewport.Y) { - Viewport = Viewport with { Y = _selected }; - SetNeedsDraw (); + Viewport = Viewport with { Y = SelectedItem.Value }; } return true; } - /// - protected override bool OnDrawingContent () - { - Attribute 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 == _selected; - - 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 OnHasFocusChanged (bool newHasFocus, [CanBeNull] View currentFocused, [CanBeNull] View newFocused) - { - if (newHasFocus && _lastSelectedItem != _selected) - { - EnsureSelectedItemVisible (); - } - } - /// Invokes the event if it is defined. /// if the event was fired. public bool OnOpenSelectedItem () { - if (_source is null || _source.Count <= _selected || _selected < 0 || OpenSelectedItem is null) + if (Source is null || SelectedItem is null || Source.Count <= SelectedItem || SelectedItem < 0 || OpenSelectedItem is null) { return false; } - object value = _source.ToList () [_selected]; + object? value = Source.ToList () [SelectedItem.Value]; - OpenSelectedItem?.Invoke (this, new ListViewItemEventArgs (_selected, value)); + OpenSelectedItem?.Invoke (this, new (SelectedItem.Value, value!)); // BUGBUG: this should not blindly return true. return true; } - /// - protected override bool OnKeyDown (Key key) + /// 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 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 _)) + if (Source is { }) { - return false; + Source.SuspendCollectionChangedEvent = false; } + } - // Enable user to find & select an item by typing text - if (KeystrokeNavigator.Matcher.IsCompatibleKey (key)) - { - int? newItem = KeystrokeNavigator?.GetNextMatchingItem (SelectedItem, (char)key); + /// This event is invoked when this is being drawn before rendering. + public event EventHandler? RowRender; - if (newItem is { } && newItem != -1) + 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) { - SelectedItem = (int)newItem; - EnsureSelectedItemVisible (); - SetNeedsDraw (); + return; + } - return true; + if (value.HasValue && (value < 0 || value >= Source.Count)) + { + throw new ArgumentException (@"SelectedItem must be greater than 0 or less than the number of items."); } - } - return false; + _selectedItem = value; + OnSelectedChanged (); + SetNeedsDraw (); + } } - /// Virtual method that will invoke the . - /// - public virtual void OnRowRender (ListViewRowEventArgs rowEventArgs) { RowRender?.Invoke (this, rowEventArgs); } - // TODO: Use standard event model /// Invokes the event if it is defined. /// public virtual bool OnSelectedChanged () { - if (_selected != _lastSelectedItem) + if (SelectedItem != _lastSelectedItem) { - object value = _source?.Count > 0 ? _source.ToList () [_selected] : null; - SelectedItemChanged?.Invoke (this, new ListViewItemEventArgs (_selected, value)); - _lastSelectedItem = _selected; + object? value = SelectedItem.HasValue && Source?.Count > 0 ? Source.ToList () [SelectedItem.Value] : null; + SelectedItemChanged?.Invoke (this, new (SelectedItem, value)); + _lastSelectedItem = SelectedItem; EnsureSelectedItemVisible (); return true; @@ -856,19 +570,8 @@ public virtual bool OnSelectedChanged () return false; } - /// This event is raised when the user Double-Clicks on an item or presses ENTER to open the selected item. - public event EventHandler OpenSelectedItem; - - /// This event is invoked when this is being drawn before rendering. - public event EventHandler RowRender; - /// This event is raised when the selected item in the has changed. - public event EventHandler SelectedItemChanged; - - /// - /// Event to raise when an item is added, removed, or moved, or the entire list is refreshed. - /// - public event NotifyCollectionChangedEventHandler CollectionChanged; + public event EventHandler? SelectedItemChanged; /// Sets the source of the to an . /// An object implementing the IList interface. @@ -876,7 +579,7 @@ public virtual bool OnSelectedChanged () /// Use the property to set a new source and use custom /// rendering. /// - public void SetSource (ObservableCollection source) + public void SetSource (ObservableCollection? source) { if (source is null && Source is not ListWrapper) { @@ -894,12 +597,12 @@ public void SetSource (ObservableCollection source) /// Use the property to set a new source and use custom /// rendering. /// - public Task SetSourceAsync (ObservableCollection source) + public Task SetSourceAsync (ObservableCollection? source) { return Task.Factory.StartNew ( () => { - if (source is null && (Source is null || !(Source is ListWrapper))) + if (source is null && Source is not ListWrapper) { Source = null; } @@ -916,23 +619,37 @@ public Task SetSourceAsync (ObservableCollection source) ); } - private void ListView_LayoutStarted (object sender, LayoutEventArgs e) { EnsureSelectedItemVisible (); } - /// - /// Call the event to raises the . - /// - /// - protected virtual void OnCollectionChanged (NotifyCollectionChangedEventArgs e) { CollectionChanged?.Invoke (this, e); } - - /// - protected override void Dispose (bool disposing) + /// Gets or sets the backing this , enabling custom rendering. + /// The source. + /// Use to set a new source. + public IListDataSource? Source { - _source?.Dispose (); + get => _source; + set + { + if (_source == value) + { + return; + } - base.Dispose (disposing); + _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, + /// Allow suspending the event from being invoked, /// public void SuspendCollectionChangedEvent () { @@ -942,245 +659,267 @@ public void SuspendCollectionChangedEvent () } } - /// - /// Allow resume the event from being invoked, - /// - public void ResumeSuspendCollectionChangedEvent () + /// 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 { - if (Source is { }) + get => Viewport.Y; + set { - Source.SuspendCollectionChangedEvent = false; - } - } - - /// - public bool EnableForDesign () - { - var source = new ListWrapper (["List Item 1", "List Item two", "List Item Quattro", "Last List Item"]); - Source = source; + if (Source is null) + { + return; + } - return true; + Viewport = Viewport with { Y = value }; + } } -} - -/// -/// Provides a default implementation of that renders items -/// using . -/// -public class ListWrapper : IListDataSource, IDisposable -{ - private int _count; - private BitArray _marks; - private readonly ObservableCollection _source; - /// - public ListWrapper (ObservableCollection source) + /// + /// If and are both , + /// unmarks all marked items other than . + /// + /// if unmarking was successful. + public bool UnmarkAllButSelected () { - if (source is { }) + if (!_allowsMarking) { - _count = source.Count; - _marks = new BitArray (_count); - _source = source; - _source.CollectionChanged += Source_CollectionChanged; - Length = GetMaxLengthItem (); + return false; } - } - private void Source_CollectionChanged (object sender, NotifyCollectionChangedEventArgs e) - { - if (!SuspendCollectionChangedEvent) + if (!AllowsMultipleSelection) { - CheckAndResizeMarksIfRequired (); - CollectionChanged?.Invoke (sender, e); + for (var i = 0; i < Source?.Count; i++) + { + if (Source.IsMarked (i) && i != SelectedItem) + { + Source.SetMark (i, false); + + return true; + } + } } - } - /// - public event NotifyCollectionChangedEventHandler CollectionChanged; + return true; + } /// - public int Count => _source?.Count ?? 0; + protected override void Dispose (bool disposing) + { + Source?.Dispose (); - /// - public int Length { get; private set; } + base.Dispose (disposing); + } - private bool _suspendCollectionChangedEvent; + /// + /// Call the event to raises the . + /// + /// + protected virtual void OnCollectionChanged (NotifyCollectionChangedEventArgs e) { CollectionChanged?.Invoke (this, e); } - /// - public bool SuspendCollectionChangedEvent + /// + protected override bool OnDrawingContent () { - get => _suspendCollectionChangedEvent; - set + if (Source is null) { - _suspendCollectionChangedEvent = value; - - if (!_suspendCollectionChangedEvent) - { - CheckAndResizeMarksIfRequired (); - } + return base.OnDrawingContent (); } - } - private void CheckAndResizeMarksIfRequired () - { - if (_source != null && _count != _source.Count) + 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++) { - _count = _source.Count; - BitArray newMarks = new BitArray (_count); - for (var i = 0; i < Math.Min (_marks.Length, newMarks.Length); i++) - { - newMarks [i] = _marks [i]; - } - _marks = newMarks; + bool isSelected = item == SelectedItem; - Length = GetMaxLengthItem (); - } - } + Attribute newAttribute = focused ? isSelected ? GetAttributeForRole (VisualRole.Focus) : GetAttributeForRole (VisualRole.Normal) : + isSelected ? GetAttributeForRole (VisualRole.Active) : GetAttributeForRole (VisualRole.Normal); - /// - public void Render ( - ListView container, - bool marked, - int item, - int col, - int line, - int width, - int start = 0 - ) - { - container.Move (Math.Max (col - start, 0), line); + if (newAttribute != current) + { + SetAttribute (newAttribute); + current = newAttribute; + } - if (_source is { }) - { - object t = _source [item]; + Move (0, row); - if (t is null) + if (Source is null || item >= Source.Count) { - RenderUstr (container, "", col, line, width); + for (var c = 0; c < f.Width; c++) + { + AddRune ((Rune)' '); + } } else { - if (t is string s) + var rowEventArgs = new ListViewRowEventArgs (item); + OnRowRender (rowEventArgs); + + if (rowEventArgs.RowAttribute is { } && current != rowEventArgs.RowAttribute) { - RenderUstr (container, s, col, line, width, start); + current = (Attribute)rowEventArgs.RowAttribute; + SetAttribute (current); } - else + + if (_allowsMarking) { - RenderUstr (container, t.ToString (), col, line, width, start); + 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; } /// - public bool IsMarked (int item) - { - if (item >= 0 && item < _count) - { - return _marks [item]; - } - - return false; - } + protected override void OnFrameChanged (in Rectangle frame) { EnsureSelectedItemVisible (); } /// - public void SetMark (int item, bool value) + protected override void OnHasFocusChanged (bool newHasFocus, View? currentFocused, View? newFocused) { - if (item >= 0 && item < _count) + if (newHasFocus && _lastSelectedItem != SelectedItem) { - _marks [item] = value; + EnsureSelectedItemVisible (); } } /// - public IList ToList () { return _source; } - - /// - public int StartsWith (string search) + protected override bool OnKeyDown (Key key) { - if (_source is null || _source?.Count == 0) + // 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 -1; + return false; } - for (var i = 0; i < _source.Count; i++) + // Enable user to find & select an item by typing text + if (KeystrokeNavigator.Matcher.IsCompatibleKey (key)) { - object t = _source [i]; + int? newItem = KeystrokeNavigator?.GetNextMatchingItem (SelectedItem ?? null, (char)key); - if (t is string u) - { - if (u.ToUpper ().StartsWith (search.ToUpperInvariant ())) - { - return i; - } - } - else if (t is string s) + if (newItem is { } && newItem != -1) { - if (s.StartsWith (search, StringComparison.InvariantCultureIgnoreCase)) - { - return i; - } + SelectedItem = (int)newItem; + EnsureSelectedItemVisible (); + SetNeedsDraw (); + + return true; } } - return -1; + return false; } - private int GetMaxLengthItem () + /// + protected override bool OnMouseEvent (MouseEventArgs me) { - if (_source is null || _source?.Count == 0) + 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 0; + return false; } - var maxLength = 0; + if (!HasFocus && CanFocus) + { + SetFocus (); + } - for (var i = 0; i < _source!.Count; i++) + if (Source is null) { - object t = _source [i]; - int l; + return false; + } - if (t is string u) - { - l = u.GetColumns (); - } - else if (t is string s) - { - l = s.Length; - } - else + if (me.Flags == MouseFlags.WheeledDown) + { + if (Viewport.Y + Viewport.Height < GetContentSize ().Height) { - l = t.ToString ().Length; + ScrollVertical (1); } - if (l > maxLength) + return true; + } + + if (me.Flags == MouseFlags.WheeledUp) + { + ScrollVertical (-1); + + return true; + } + + if (me.Flags == MouseFlags.WheeledRight) + { + if (Viewport.X + Viewport.Width < GetContentSize ().Width) { - maxLength = l; + ScrollHorizontal (1); } + + return true; } - return maxLength; - } + if (me.Flags == MouseFlags.WheeledLeft) + { + ScrollHorizontal (-1); - private void RenderUstr (View driver, string ustr, int col, int line, int width, int start = 0) - { - string str = start > ustr.GetColumns () ? string.Empty : ustr.Substring (Math.Min (start, ustr.ToRunes ().Length - 1)); - string u = TextFormatter.ClipAndJustify (str, width, Alignment.Start); - driver.AddStr (u); - width -= u.GetColumns (); + return true; + } - while (width-- > 0) + if (me.Position.Y + Viewport.Y >= Source.Count + || me.Position.Y + Viewport.Y < 0 + || me.Position.Y + Viewport.Y > Viewport.Y + Viewport.Height) { - driver.AddRune ((Rune)' '); + 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; } - /// - public void Dispose () + /// + protected override void OnViewportChanged (DrawEventArgs e) { SetContentSize (new Size (MaxLength, Source?.Count ?? Viewport.Height)); } + + private void Source_CollectionChanged (object? sender, NotifyCollectionChangedEventArgs e) { - if (_source is { }) + SetContentSize (new Size (Source?.Length ?? Viewport.Width, Source?.Count ?? Viewport.Width)); + + if (Source is { Count: > 0 } && SelectedItem.HasValue && SelectedItem > Source.Count - 1) { - _source.CollectionChanged -= Source_CollectionChanged; + SelectedItem = Source.Count - 1; } + + SetNeedsDraw (); + + OnCollectionChanged (e); } } diff --git a/Terminal.Gui/Views/ListViewEventArgs.cs b/Terminal.Gui/Views/ListViewEventArgs.cs index 7a718b536b..e7a8c26866 100644 --- a/Terminal.Gui/Views/ListViewEventArgs.cs +++ b/Terminal.Gui/Views/ListViewEventArgs.cs @@ -1,5 +1,4 @@ -#nullable disable -namespace Terminal.Gui.Views; +namespace Terminal.Gui.Views; /// for events. public class ListViewItemEventArgs : EventArgs @@ -7,17 +6,17 @@ public class ListViewItemEventArgs : EventArgs /// Initializes a new instance of /// The index of the item. /// The item - public ListViewItemEventArgs (int item, object value) + public ListViewItemEventArgs (int? item, object? value) { Item = item; Value = value; } /// The index of the item. - public int Item { get; } + public int? Item { get; } /// The item. - public object Value { get; } + public object? Value { get; } } /// used by the event. diff --git a/Terminal.Gui/Views/ListWrapper.cs b/Terminal.Gui/Views/ListWrapper.cs new file mode 100644 index 0000000000..5f10b4e06f --- /dev/null +++ b/Terminal.Gui/Views/ListWrapper.cs @@ -0,0 +1,256 @@ +#nullable enable +using System.Collections; +using System.Collections.ObjectModel; +using System.Collections.Specialized; + +namespace Terminal.Gui.Views; + +/// +/// Provides a default implementation of that renders items +/// using . +/// +public class ListWrapper : IListDataSource, IDisposable +{ + /// + /// Creates a new instance of that wraps the specified + /// . + /// + /// + public ListWrapper (ObservableCollection? source) + { + if (source is { }) + { + _count = source.Count; + _marks = new (_count); + _source = source; + _source.CollectionChanged += Source_CollectionChanged; + Length = GetMaxLengthItem (); + } + } + + private readonly ObservableCollection? _source; + private int _count; + private BitArray? _marks; + + private bool _suspendCollectionChangedEvent; + + /// + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + /// + public int Count => _source?.Count ?? 0; + + /// + public int Length { get; private set; } + + /// + public bool SuspendCollectionChangedEvent + { + get => _suspendCollectionChangedEvent; + set + { + _suspendCollectionChangedEvent = value; + + if (!_suspendCollectionChangedEvent) + { + CheckAndResizeMarksIfRequired (); + } + } + } + + /// + public void Render ( + ListView container, + bool marked, + int item, + int col, + int line, + int width, + int viewportX = 0 + ) + { + container.Move (Math.Max (col - viewportX, 0), line); + + if (_source is null) + { + return; + } + + object? t = _source [item]; + + if (t is null) + { + RenderString (container, "", col, line, width); + } + else + { + if (t is string s) + { + RenderString (container, s, col, line, width, viewportX); + } + else + { + RenderString (container, t.ToString ()!, col, line, width, viewportX); + } + } + } + + /// + 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 _source ?? []; } + + /// + public void Dispose () + { + if (_source is { }) + { + _source.CollectionChanged -= Source_CollectionChanged; + } + } + + /// + /// INTERNAL: Searches the underlying collection for the first string element that starts with the specified search value, + /// using a case-insensitive comparison. + /// + /// + /// The comparison is performed in a case-insensitive manner using invariant culture rules. Only + /// elements of type string are considered; other types in the collection are ignored. + /// + /// + /// The string value to compare against the start of each string element in the collection. Cannot be + /// null. + /// + /// + /// The zero-based index of the first matching string element if found; otherwise, -1 if no match is found or the + /// collection is empty. + /// + internal int StartsWith (string search) + { + if (_source is null || _source?.Count == 0) + { + return -1; + } + + for (var i = 0; i < _source!.Count; i++) + { + object? t = _source [i]; + + if (t is string u) + { + if (u.ToUpper ().StartsWith (search.ToUpperInvariant ())) + { + return i; + } + } + else if (t is string s && s.StartsWith (search, StringComparison.InvariantCultureIgnoreCase)) + { + return i; + } + } + + return -1; + } + + private void CheckAndResizeMarksIfRequired () + { + if (_source != null && _count != _source.Count && _marks is { }) + { + _count = _source.Count; + var newMarks = new BitArray (_count); + + for (var i = 0; i < Math.Min (_marks.Length, newMarks.Length); i++) + { + newMarks [i] = _marks [i]; + } + + _marks = newMarks; + + Length = GetMaxLengthItem (); + } + } + + private int GetMaxLengthItem () + { + if (_source is null || _source?.Count == 0) + { + return 0; + } + + var maxLength = 0; + + for (var i = 0; i < _source!.Count; i++) + { + object? t = _source [i]; + + if (t is null) + { + continue; + } + + int l; + + l = t is string u ? u.GetColumns () : t.ToString ()!.Length; + + if (l > maxLength) + { + maxLength = l; + } + } + + return maxLength; + } + + private static void RenderString (View driver, string str, int col, int line, int width, int viewportX = 0) + { + if (string.IsNullOrEmpty (str) || viewportX >= str.GetColumns ()) + { + // Empty string or viewport beyond string - just fill with spaces + for (var i = 0; i < width; i++) + { + driver.AddRune ((Rune)' '); + } + + return; + } + + int runeLength = str.ToRunes ().Length; + int startIndex = Math.Min (viewportX, Math.Max (0, runeLength - 1)); + string substring = str.Substring (startIndex); + string u = TextFormatter.ClipAndJustify (substring, width, Alignment.Start); + driver.AddStr (u); + width -= u.GetColumns (); + + while (width-- > 0) + { + driver.AddRune ((Rune)' '); + } + } + + private void Source_CollectionChanged (object? sender, NotifyCollectionChangedEventArgs e) + { + if (!SuspendCollectionChangedEvent) + { + CheckAndResizeMarksIfRequired (); + CollectionChanged?.Invoke (sender, e); + } + } +} diff --git a/Terminal.Gui/Views/TableView/TableView.cs b/Terminal.Gui/Views/TableView/TableView.cs index f09532ed2f..1c5f298e0d 100644 --- a/Terminal.Gui/Views/TableView/TableView.cs +++ b/Terminal.Gui/Views/TableView/TableView.cs @@ -1607,11 +1607,11 @@ private bool CycleToNextTableEntryBeginningWith (Key key) return false; } - int match = CollectionNavigator.GetNextMatchingItem (row, (char)key); + int? match = CollectionNavigator.GetNextMatchingItem (row, (char)key); - if (match != -1) + if (match != null) { - SelectedRow = match; + SelectedRow = match.Value; EnsureValidSelection (); EnsureSelectedCellIsVisible (); SetNeedsDraw (); diff --git a/Terminal.Gui/Views/Toplevel.cs b/Terminal.Gui/Views/Toplevel.cs index 9f3854f7e1..fedc501f43 100644 --- a/Terminal.Gui/Views/Toplevel.cs +++ b/Terminal.Gui/Views/Toplevel.cs @@ -82,7 +82,7 @@ public Toplevel () // TODO: IRunnable: Re-implement as a property on IRunnable /// Gets or sets whether the main loop for this is running or not. - /// Setting this property directly is discouraged. Use instead. + /// Setting this property directly is discouraged. Use instead. public bool Running { get; set; } // TODO: IRunnable: Re-implement in IRunnable diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index 4161569b11..ef25662a47 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -421,6 +421,7 @@ True True True + True True True True diff --git a/Tests/IntegrationTests/UICatalog/ScenarioTests.cs b/Tests/IntegrationTests/UICatalog/ScenarioTests.cs index 69021ab153..39376e87be 100644 --- a/Tests/IntegrationTests/UICatalog/ScenarioTests.cs +++ b/Tests/IntegrationTests/UICatalog/ScenarioTests.cs @@ -317,7 +317,7 @@ public void Run_All_Views_Tester_Scenario () hostPane.FillRect (hostPane.Viewport); } - curView = CreateClass (viewClasses.Values.ToArray () [classListView.SelectedItem]); + curView = CreateClass (viewClasses.Values.ToArray () [classListView.SelectedItem!.Value]); }; xOptionSelector.ValueChanged += (_, _) => DimPosChanged (curView); @@ -404,7 +404,7 @@ void OnApplicationOnIteration (object? s, IterationEventArgs a) { Assert.Equal ( curView.GetType ().Name, - viewClasses.Values.ToArray () [classListView.SelectedItem].Name); + viewClasses.Values.ToArray () [classListView.SelectedItem!.Value].Name); } } else diff --git a/Tests/TerminalGuiFluentTesting/GuiTestContext.ContextMenu.cs b/Tests/TerminalGuiFluentTesting/GuiTestContext.ContextMenu.cs index 359fd7a0a5..136d4d0856 100644 --- a/Tests/TerminalGuiFluentTesting/GuiTestContext.ContextMenu.cs +++ b/Tests/TerminalGuiFluentTesting/GuiTestContext.ContextMenu.cs @@ -24,7 +24,7 @@ public GuiTestContext WithContextMenu (PopoverMenu? contextMenu) { // Registering with the PopoverManager will ensure that the context menu is closed when the view is no longer focused // and the context menu is disposed when it is closed. - App.Popover?.Register (contextMenu); + App?.Popover?.Register (contextMenu); contextMenu?.MakeVisible (e.ScreenPosition); } }; diff --git a/Tests/TerminalGuiFluentTesting/GuiTestContext.ViewBase.cs b/Tests/TerminalGuiFluentTesting/GuiTestContext.ViewBase.cs index 74eafc77ee..f96505840a 100644 --- a/Tests/TerminalGuiFluentTesting/GuiTestContext.ViewBase.cs +++ b/Tests/TerminalGuiFluentTesting/GuiTestContext.ViewBase.cs @@ -28,11 +28,11 @@ public GuiTestContext Add (View v) /// /// The last view added (e.g. with ) or the root/current top. /// - public View LastView => _lastView ?? App.Current ?? throw new ("Could not determine which view to add to"); + public View LastView => _lastView ?? App?.Current ?? throw new ("Could not determine which view to add to"); private T Find (Func evaluator) where T : View { - Toplevel? t = App.Current; + Toplevel? t = App?.Current; if (t == null) { diff --git a/Tests/UnitTests/SetupFakeApplicationAttribute.cs b/Tests/UnitTests/SetupFakeApplicationAttribute.cs index 0b8633da7d..06d338436d 100644 --- a/Tests/UnitTests/SetupFakeApplicationAttribute.cs +++ b/Tests/UnitTests/SetupFakeApplicationAttribute.cs @@ -32,7 +32,10 @@ public override void After (MethodInfo methodUnderTest) _appDispose?.Dispose (); _appDispose = null; - ApplicationImpl.SetInstance (null); + + // TODO: This is troublesome; it seems to cause tests to hang when enabled, but shouldn't have any impact. + // TODO: Uncomment after investigation. + //ApplicationImpl.SetInstance (null); base.After (methodUnderTest); } diff --git a/Tests/UnitTests/Views/ListViewTests.cs b/Tests/UnitTests/Views/ListViewTests.cs deleted file mode 100644 index d428277608..0000000000 --- a/Tests/UnitTests/Views/ListViewTests.cs +++ /dev/null @@ -1,1225 +0,0 @@ -using System.Collections; -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using Moq; -using UnitTests; -using Xunit.Abstractions; - -namespace UnitTests.ViewsTests; - -public class ListViewTests (ITestOutputHelper output) -{ - [Fact] - public void Constructors_Defaults () - { - var lv = new ListView (); - Assert.Null (lv.Source); - Assert.True (lv.CanFocus); - Assert.Equal (-1, lv.SelectedItem); - Assert.False (lv.AllowsMultipleSelection); - - lv = new () { Source = new ListWrapper (["One", "Two", "Three"]) }; - Assert.NotNull (lv.Source); - Assert.Equal (-1, lv.SelectedItem); - - lv = new () { Source = new NewListDataSource () }; - Assert.NotNull (lv.Source); - Assert.Equal (-1, lv.SelectedItem); - - lv = new () - { - Y = 1, Width = 10, Height = 20, Source = new ListWrapper (["One", "Two", "Three"]) - }; - Assert.NotNull (lv.Source); - Assert.Equal (-1, lv.SelectedItem); - Assert.Equal (new (0, 1, 10, 20), lv.Frame); - - lv = new () { Y = 1, Width = 10, Height = 20, Source = new NewListDataSource () }; - Assert.NotNull (lv.Source); - Assert.Equal (-1, lv.SelectedItem); - Assert.Equal (new (0, 1, 10, 20), lv.Frame); - - } - - [Fact] - [AutoInitShutdown] - public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () - { - ObservableCollection source = []; - - for (var i = 0; i < 20; i++) - { - source.Add ($"Line{i}"); - } - - var lv = new ListView { Width = Dim.Fill (), Height = Dim.Fill (), Source = new ListWrapper (source) }; - var win = new Window (); - win.Add (lv); - var top = new Toplevel (); - top.Add (win); - SessionToken rs = Application.Begin (top); - Application.Driver!.SetScreenSize (12, 12); - AutoInitShutdownAttribute.RunIteration (); - - Assert.Equal (-1, lv.SelectedItem); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -┌──────────┐ -│Line0 │ -│Line1 │ -│Line2 │ -│Line3 │ -│Line4 │ -│Line5 │ -│Line6 │ -│Line7 │ -│Line8 │ -│Line9 │ -└──────────┘", - output - ); - - Assert.True (lv.ScrollVertical (10)); - AutoInitShutdownAttribute.RunIteration (); - Assert.Equal (-1, lv.SelectedItem); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -┌──────────┐ -│Line10 │ -│Line11 │ -│Line12 │ -│Line13 │ -│Line14 │ -│Line15 │ -│Line16 │ -│Line17 │ -│Line18 │ -│Line19 │ -└──────────┘", - output - ); - - Assert.True (lv.MoveDown ()); - AutoInitShutdownAttribute.RunIteration (); - Assert.Equal (0, lv.SelectedItem); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -┌──────────┐ -│Line0 │ -│Line1 │ -│Line2 │ -│Line3 │ -│Line4 │ -│Line5 │ -│Line6 │ -│Line7 │ -│Line8 │ -│Line9 │ -└──────────┘", - output - ); - - Assert.True (lv.MoveEnd ()); - AutoInitShutdownAttribute.RunIteration (); - Assert.Equal (19, lv.SelectedItem); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -┌──────────┐ -│Line10 │ -│Line11 │ -│Line12 │ -│Line13 │ -│Line14 │ -│Line15 │ -│Line16 │ -│Line17 │ -│Line18 │ -│Line19 │ -└──────────┘", - output - ); - - Assert.True (lv.ScrollVertical (-20)); - AutoInitShutdownAttribute.RunIteration (); - Assert.Equal (19, lv.SelectedItem); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -┌──────────┐ -│Line0 │ -│Line1 │ -│Line2 │ -│Line3 │ -│Line4 │ -│Line5 │ -│Line6 │ -│Line7 │ -│Line8 │ -│Line9 │ -└──────────┘", - output - ); - - Assert.True (lv.MoveDown ()); - AutoInitShutdownAttribute.RunIteration (); - Assert.Equal (19, lv.SelectedItem); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -┌──────────┐ -│Line10 │ -│Line11 │ -│Line12 │ -│Line13 │ -│Line14 │ -│Line15 │ -│Line16 │ -│Line17 │ -│Line18 │ -│Line19 │ -└──────────┘", - output - ); - - Assert.True (lv.ScrollVertical (-20)); - AutoInitShutdownAttribute.RunIteration (); - Assert.Equal (19, lv.SelectedItem); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -┌──────────┐ -│Line0 │ -│Line1 │ -│Line2 │ -│Line3 │ -│Line4 │ -│Line5 │ -│Line6 │ -│Line7 │ -│Line8 │ -│Line9 │ -└──────────┘", - output - ); - - Assert.True (lv.MoveDown ()); - AutoInitShutdownAttribute.RunIteration (); - Assert.Equal (19, lv.SelectedItem); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -┌──────────┐ -│Line10 │ -│Line11 │ -│Line12 │ -│Line13 │ -│Line14 │ -│Line15 │ -│Line16 │ -│Line17 │ -│Line18 │ -│Line19 │ -└──────────┘", - output - ); - - Assert.True (lv.MoveHome ()); - AutoInitShutdownAttribute.RunIteration (); - Assert.Equal (0, lv.SelectedItem); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -┌──────────┐ -│Line0 │ -│Line1 │ -│Line2 │ -│Line3 │ -│Line4 │ -│Line5 │ -│Line6 │ -│Line7 │ -│Line8 │ -│Line9 │ -└──────────┘", - output - ); - - Assert.True (lv.ScrollVertical (20)); - AutoInitShutdownAttribute.RunIteration (); - Assert.Equal (0, lv.SelectedItem); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -┌──────────┐ -│Line19 │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -└──────────┘", - output - ); - - Assert.True (lv.MoveUp ()); - AutoInitShutdownAttribute.RunIteration (); - Assert.Equal (0, lv.SelectedItem); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -┌──────────┐ -│Line0 │ -│Line1 │ -│Line2 │ -│Line3 │ -│Line4 │ -│Line5 │ -│Line6 │ -│Line7 │ -│Line8 │ -│Line9 │ -└──────────┘", - output - ); - top.Dispose (); - } - - [Fact] - [AutoInitShutdown] - public void EnsureSelectedItemVisible_SelectedItem () - { - ObservableCollection source = []; - - for (var i = 0; i < 10; i++) - { - source.Add ($"Item {i}"); - } - - var lv = new ListView { Width = 10, Height = 5, Source = new ListWrapper (source) }; - var top = new Toplevel (); - top.Add (lv); - Application.Begin (top); - AutoInitShutdownAttribute.RunIteration (); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -Item 0 -Item 1 -Item 2 -Item 3 -Item 4", - output - ); - - // EnsureSelectedItemVisible is auto enabled on the OnSelectedChanged - lv.SelectedItem = 6; - AutoInitShutdownAttribute.RunIteration (); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -Item 2 -Item 3 -Item 4 -Item 5 -Item 6", - output - ); - top.Dispose (); - } - - [Fact] - [AutoInitShutdown] - public void EnsureSelectedItemVisible_Top () - { - ObservableCollection source = ["First", "Second"]; - var lv = new ListView { Width = Dim.Fill (), Height = 1, Source = new ListWrapper (source) }; - lv.SelectedItem = 1; - var top = new Toplevel (); - top.Add (lv); - Application.Begin (top); - AutoInitShutdownAttribute.RunIteration (); - - Assert.Equal ("Second ", GetContents (0)); - Assert.Equal (new (' ', 7), GetContents (1)); - - lv.MoveUp (); - lv.Draw (); - - Assert.Equal ("First ", GetContents (0)); - Assert.Equal (new (' ', 7), GetContents (1)); - - string GetContents (int line) - { - var item = ""; - - for (var i = 0; i < 7; i++) - { - item += Application.Driver?.Contents [line, i].Rune; - } - - return item; - } - top.Dispose (); - } - - [Fact] - public void KeyBindings_Command () - { - ObservableCollection source = ["One", "Two", "Three"]; - var lv = new ListView { Height = 2, AllowsMarking = true, Source = new ListWrapper (source) }; - lv.BeginInit (); - lv.EndInit (); - Assert.Equal (-1, lv.SelectedItem); - Assert.True (lv.NewKeyDownEvent (Key.CursorDown)); - Assert.Equal (0, lv.SelectedItem); - Assert.True (lv.NewKeyDownEvent (Key.CursorUp)); - Assert.Equal (0, lv.SelectedItem); - Assert.True (lv.NewKeyDownEvent (Key.PageDown)); - Assert.Equal (2, lv.SelectedItem); - Assert.Equal (2, lv.TopItem); - Assert.True (lv.NewKeyDownEvent (Key.PageUp)); - Assert.Equal (0, lv.SelectedItem); - Assert.Equal (0, lv.TopItem); - Assert.False (lv.Source.IsMarked (lv.SelectedItem)); - Assert.True (lv.NewKeyDownEvent (Key.Space)); - Assert.True (lv.Source.IsMarked (lv.SelectedItem)); - var opened = false; - lv.OpenSelectedItem += (s, _) => opened = true; - Assert.True (lv.NewKeyDownEvent (Key.Enter)); - Assert.True (opened); - Assert.True (lv.NewKeyDownEvent (Key.End)); - Assert.Equal (2, lv.SelectedItem); - Assert.True (lv.NewKeyDownEvent (Key.Home)); - Assert.Equal (0, lv.SelectedItem); - } - - [Fact] - public void HotKey_Command_SetsFocus () - { - var view = new ListView (); - - view.CanFocus = true; - Assert.False (view.HasFocus); - view.InvokeCommand (Command.HotKey); - Assert.True (view.HasFocus); - } - - [Fact] - public void HotKey_Command_Does_Not_Accept () - { - var listView = new ListView (); - var accepted = false; - - listView.Accepting += OnAccepted; - listView.InvokeCommand (Command.HotKey); - - Assert.False (accepted); - - return; - - void OnAccepted (object sender, CommandEventArgs e) { accepted = true; } - } - - [Fact] - public void Accept_Command_Accepts_and_Opens_Selected_Item () - { - ObservableCollection source = ["One", "Two", "Three"]; - var listView = new ListView { Source = new ListWrapper (source) }; - listView.SelectedItem = 0; - - var accepted = false; - var opened = false; - var selectedValue = string.Empty; - - listView.Accepting += Accepted; - listView.OpenSelectedItem += OpenSelectedItem; - - listView.InvokeCommand (Command.Accept); - - Assert.True (accepted); - Assert.True (opened); - Assert.Equal (source [0], selectedValue); - - return; - - void OpenSelectedItem (object sender, ListViewItemEventArgs e) - { - opened = true; - selectedValue = e.Value.ToString (); - } - - void Accepted (object sender, CommandEventArgs e) { accepted = true; } - } - - [Fact] - public void Accept_Cancel_Event_Prevents_OpenSelectedItem () - { - ObservableCollection source = ["One", "Two", "Three"]; - var listView = new ListView { Source = new ListWrapper (source) }; - listView.SelectedItem = 0; - - var accepted = false; - var opened = false; - var selectedValue = string.Empty; - - listView.Accepting += Accepted; - listView.OpenSelectedItem += OpenSelectedItem; - - listView.InvokeCommand (Command.Accept); - - Assert.True (accepted); - Assert.False (opened); - Assert.Equal (string.Empty, selectedValue); - - return; - - void OpenSelectedItem (object sender, ListViewItemEventArgs e) - { - opened = true; - selectedValue = e.Value.ToString (); - } - - void Accepted (object sender, CommandEventArgs e) - { - accepted = true; - e.Handled = true; - } - } - - /// - /// Tests that when none of the Commands in a chained keybinding are possible the - /// returns the appropriate result - /// - [Fact] - public void ListViewProcessKeyReturnValue_WithMultipleCommands () - { - var lv = new ListView { Source = new ListWrapper (["One", "Two", "Three", "Four"]) }; - - Assert.NotNull (lv.Source); - - // first item should be deselected by default - Assert.Equal (-1, lv.SelectedItem); - - // bind shift down to move down twice in control - lv.KeyBindings.Add (Key.CursorDown.WithShift, Command.Down, Command.Down); - - Key ev = Key.CursorDown.WithShift; - - Assert.True (lv.NewKeyDownEvent (ev), "The first time we move down 2 it should be possible"); - - // After moving down twice from -1 we should be at 'Two' - Assert.Equal (1, lv.SelectedItem); - - // clear the items - lv.SetSource (null); - - // Press key combo again - return should be false this time as none of the Commands are allowable - Assert.False (lv.NewKeyDownEvent (ev), "We cannot move down so will not respond to this"); - } - - [Fact] - public void AllowsMarking_True_SpaceWithShift_SelectsThenDown_SingleSelection () - { - var lv = new ListView { Source = new ListWrapper (["One", "Two", "Three"]) }; - lv.AllowsMarking = true; - lv.AllowsMultipleSelection = false; - - Assert.NotNull (lv.Source); - - // first item should be deselected by default - Assert.Equal (-1, lv.SelectedItem); - - // nothing is ticked - Assert.False (lv.Source.IsMarked (0)); - Assert.False (lv.Source.IsMarked (1)); - Assert.False (lv.Source.IsMarked (2)); - - // view should indicate that it has accepted and consumed the event - Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); - - // first item should now be selected - Assert.Equal (0, lv.SelectedItem); - - // none of the items should be ticked - Assert.False (lv.Source.IsMarked (0)); - Assert.False (lv.Source.IsMarked (1)); - Assert.False (lv.Source.IsMarked (2)); - - // Press key combo again - Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); - - // second item should now be selected - Assert.Equal (1, lv.SelectedItem); - - // first item only should be ticked - Assert.True (lv.Source.IsMarked (0)); - Assert.False (lv.Source.IsMarked (1)); - Assert.False (lv.Source.IsMarked (2)); - - // Press key combo again - Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); - Assert.Equal (2, lv.SelectedItem); - Assert.False (lv.Source.IsMarked (0)); - Assert.True (lv.Source.IsMarked (1)); - Assert.False (lv.Source.IsMarked (2)); - - // Press key combo again - Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); - Assert.Equal (2, lv.SelectedItem); // cannot move down any further - Assert.False (lv.Source.IsMarked (0)); - Assert.False (lv.Source.IsMarked (1)); - Assert.True (lv.Source.IsMarked (2)); // but can toggle marked - - // Press key combo again - Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); - Assert.Equal (2, lv.SelectedItem); // cannot move down any further - Assert.False (lv.Source.IsMarked (0)); - Assert.False (lv.Source.IsMarked (1)); - Assert.False (lv.Source.IsMarked (2)); // untoggle toggle marked - } - - [Fact] - public void AllowsMarking_True_SpaceWithShift_SelectsThenDown_MultipleSelection () - { - var lv = new ListView { Source = new ListWrapper (["One", "Two", "Three"]) }; - lv.AllowsMarking = true; - lv.AllowsMultipleSelection = true; - - Assert.NotNull (lv.Source); - - // first item should be deselected by default - Assert.Equal (-1, lv.SelectedItem); - - // nothing is ticked - Assert.False (lv.Source.IsMarked (0)); - Assert.False (lv.Source.IsMarked (1)); - Assert.False (lv.Source.IsMarked (2)); - - // view should indicate that it has accepted and consumed the event - Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); - - // first item should now be selected - Assert.Equal (0, lv.SelectedItem); - - // none of the items should be ticked - Assert.False (lv.Source.IsMarked (0)); - Assert.False (lv.Source.IsMarked (1)); - Assert.False (lv.Source.IsMarked (2)); - - // Press key combo again - Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); - - // second item should now be selected - Assert.Equal (1, lv.SelectedItem); - - // first item only should be ticked - Assert.True (lv.Source.IsMarked (0)); - Assert.False (lv.Source.IsMarked (1)); - Assert.False (lv.Source.IsMarked (2)); - - // Press key combo again - Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); - Assert.Equal (2, lv.SelectedItem); - Assert.True (lv.Source.IsMarked (0)); - Assert.True (lv.Source.IsMarked (1)); - Assert.False (lv.Source.IsMarked (2)); - - // Press key combo again - Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); - Assert.Equal (2, lv.SelectedItem); // cannot move down any further - Assert.True (lv.Source.IsMarked (0)); - Assert.True (lv.Source.IsMarked (1)); - Assert.True (lv.Source.IsMarked (2)); // but can toggle marked - - // Press key combo again - Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); - Assert.Equal (2, lv.SelectedItem); // cannot move down any further - Assert.True (lv.Source.IsMarked (0)); - Assert.True (lv.Source.IsMarked (1)); - Assert.False (lv.Source.IsMarked (2)); // untoggle toggle marked - } - - [Fact] - public void ListWrapper_StartsWith () - { - var lw = new ListWrapper (["One", "Two", "Three"]); - - Assert.Equal (1, lw.StartsWith ("t")); - Assert.Equal (1, lw.StartsWith ("tw")); - Assert.Equal (2, lw.StartsWith ("th")); - Assert.Equal (1, lw.StartsWith ("T")); - Assert.Equal (1, lw.StartsWith ("TW")); - Assert.Equal (2, lw.StartsWith ("TH")); - - lw = new (["One", "Two", "Three"]); - - Assert.Equal (1, lw.StartsWith ("t")); - Assert.Equal (1, lw.StartsWith ("tw")); - Assert.Equal (2, lw.StartsWith ("th")); - Assert.Equal (1, lw.StartsWith ("T")); - Assert.Equal (1, lw.StartsWith ("TW")); - Assert.Equal (2, lw.StartsWith ("TH")); - } - - [Fact] - public void OnEnter_Does_Not_Throw_Exception () - { - var lv = new ListView (); - var top = new View (); - top.Add (lv); - Exception exception = Record.Exception (() => lv.SetFocus ()); - Assert.Null (exception); - } - - [Fact] - [AutoInitShutdown] - public void RowRender_Event () - { - var rendered = false; - ObservableCollection source = ["one", "two", "three"]; - var lv = new ListView { Width = Dim.Fill (), Height = Dim.Fill () }; - lv.RowRender += (s, _) => rendered = true; - var top = new Toplevel (); - top.Add (lv); - Application.Begin (top); - Assert.False (rendered); - - lv.SetSource (source); - lv.Draw (); - Assert.True (rendered); - top.Dispose (); - } - - [Fact] - public void SelectedItem_Get_Set () - { - var lv = new ListView { Source = new ListWrapper (["One", "Two", "Three"]) }; - Assert.Equal (-1, lv.SelectedItem); - Assert.Throws (() => lv.SelectedItem = 3); - Exception exception = Record.Exception (() => lv.SelectedItem = -1); - Assert.Null (exception); - } - - [Fact] - public void SetSource_Preserves_ListWrapper_Instance_If_Not_Null () - { - var lv = new ListView { Source = new ListWrapper (["One", "Two"]) }; - - Assert.NotNull (lv.Source); - - lv.SetSource (null); - Assert.NotNull (lv.Source); - - lv.Source = null; - Assert.Null (lv.Source); - - lv = new () { Source = new ListWrapper (["One", "Two"]) }; - Assert.NotNull (lv.Source); - - lv.SetSourceAsync (null); - Assert.NotNull (lv.Source); - } - - [Fact] - public void SettingEmptyKeybindingThrows () - { - var lv = new ListView { Source = new ListWrapper (["One", "Two", "Three"]) }; - Assert.Throws (() => lv.KeyBindings.Add (Key.Space)); - } - - private class NewListDataSource : IListDataSource - { -#pragma warning disable CS0067 - /// - public event NotifyCollectionChangedEventHandler CollectionChanged; -#pragma warning restore CS0067 - - public int Count => 0; - public int Length => 0; - - public bool SuspendCollectionChangedEvent { get => throw new NotImplementedException (); set => throw new NotImplementedException (); } - - public bool IsMarked (int item) { throw new NotImplementedException (); } - - public void Render ( - ListView container, - bool selected, - int item, - int col, - int line, - int width, - int start = 0 - ) - { - throw new NotImplementedException (); - } - - public void SetMark (int item, bool value) { throw new NotImplementedException (); } - public IList ToList () { return new List { "One", "Two", "Three" }; } - - public void Dispose () - { - throw new NotImplementedException (); - } - } - - [Fact] - [AutoInitShutdown] - public void Clicking_On_Border_Is_Ignored () - { - var selected = ""; - - var lv = new ListView - { - Height = 5, - Width = 7, - BorderStyle = LineStyle.Single - }; - lv.SetSource (["One", "Two", "Three", "Four"]); - lv.SelectedItemChanged += (s, e) => selected = e.Value.ToString (); - var top = new Toplevel (); - top.Add (lv); - Application.Begin (top); - AutoInitShutdownAttribute.RunIteration (); - - Assert.Equal (new (1), lv.Border!.Thickness); - Assert.Equal (-1, lv.SelectedItem); - Assert.Equal ("", lv.Text); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -┌─────┐ -│One │ -│Two │ -│Three│ -└─────┘", - output); - - Application.RaiseMouseEvent (new () { ScreenPosition = new (0, 0), Flags = MouseFlags.Button1Clicked }); - Assert.Equal ("", selected); - Assert.Equal (-1, lv.SelectedItem); - - Application.RaiseMouseEvent ( - new () - { - ScreenPosition = new (1, 1), Flags = MouseFlags.Button1Clicked - }); - Assert.Equal ("One", selected); - Assert.Equal (0, lv.SelectedItem); - - Application.RaiseMouseEvent ( - new () - { - ScreenPosition = new (1, 2), Flags = MouseFlags.Button1Clicked - }); - Assert.Equal ("Two", selected); - Assert.Equal (1, lv.SelectedItem); - - Application.RaiseMouseEvent ( - new () - { - ScreenPosition = new (1, 3), Flags = MouseFlags.Button1Clicked - }); - Assert.Equal ("Three", selected); - Assert.Equal (2, lv.SelectedItem); - - Application.RaiseMouseEvent ( - new () - { - ScreenPosition = new (1, 4), Flags = MouseFlags.Button1Clicked - }); - Assert.Equal ("Three", selected); - Assert.Equal (2, lv.SelectedItem); - top.Dispose (); - } - - [Fact] - [AutoInitShutdown] - public void LeftItem_TopItem_Tests () - { - ObservableCollection source = []; - - for (int i = 0; i < 5; i++) - { - source.Add ($"Item {i}"); - } - - var lv = new ListView - { - X = 1, - Source = new ListWrapper (source) - }; - lv.Height = lv.Source.Count; - lv.Width = lv.MaxLength; - var top = new Toplevel (); - top.Add (lv); - Application.Begin (top); - AutoInitShutdownAttribute.RunIteration (); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" - Item 0 - Item 1 - Item 2 - Item 3 - Item 4", - output); - - lv.LeftItem = 1; - lv.TopItem = 1; - AutoInitShutdownAttribute.RunIteration (); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" - tem 1 - tem 2 - tem 3 - tem 4", - output); - top.Dispose (); - } - - [Fact] - public void CollectionChanged_Event () - { - var added = 0; - var removed = 0; - ObservableCollection source = []; - var lv = new ListView { Source = new ListWrapper (source) }; - - lv.CollectionChanged += (sender, args) => - { - if (args.Action == NotifyCollectionChangedAction.Add) - { - added++; - } - else if (args.Action == NotifyCollectionChangedAction.Remove) - { - removed++; - } - }; - - for (int i = 0; i < 3; i++) - { - source.Add ($"Item{i}"); - } - Assert.Equal (3, added); - Assert.Equal (0, removed); - - added = 0; - - for (int i = 0; i < 3; i++) - { - source.Remove (source [0]); - } - Assert.Equal (0, added); - Assert.Equal (3, removed); - Assert.Empty (source); - } - - [Fact] - public void CollectionChanged_Event_Is_Only_Subscribed_Once () - { - var added = 0; - var removed = 0; - var otherActions = 0; - IList source1 = []; - var lv = new ListView { Source = new ListWrapper (new (source1)) }; - - lv.CollectionChanged += (sender, args) => - { - if (args.Action == NotifyCollectionChangedAction.Add) - { - added++; - } - else if (args.Action == NotifyCollectionChangedAction.Remove) - { - removed++; - } - else - { - otherActions++; - } - }; - - ObservableCollection source2 = []; - lv.Source = new ListWrapper (source2); - ObservableCollection source3 = []; - lv.Source = new ListWrapper (source3); - Assert.Equal (0, added); - Assert.Equal (0, removed); - Assert.Equal (0, otherActions); - - for (int i = 0; i < 3; i++) - { - source1.Add ($"Item{i}"); - source2.Add ($"Item{i}"); - source3.Add ($"Item{i}"); - } - Assert.Equal (3, added); - Assert.Equal (0, removed); - Assert.Equal (0, otherActions); - - added = 0; - - for (int i = 0; i < 3; i++) - { - source1.Remove (source1 [0]); - source2.Remove (source2 [0]); - source3.Remove (source3 [0]); - } - Assert.Equal (0, added); - Assert.Equal (3, removed); - Assert.Equal (0, otherActions); - Assert.Empty (source1); - Assert.Empty (source2); - Assert.Empty (source3); - } - - [Fact] - public void CollectionChanged_Event_UnSubscribe_Previous_If_New_Is_Null () - { - var added = 0; - var removed = 0; - var otherActions = 0; - ObservableCollection source1 = []; - var lv = new ListView { Source = new ListWrapper (source1) }; - - lv.CollectionChanged += (sender, args) => - { - if (args.Action == NotifyCollectionChangedAction.Add) - { - added++; - } - else if (args.Action == NotifyCollectionChangedAction.Remove) - { - removed++; - } - else - { - otherActions++; - } - }; - - lv.Source = new ListWrapper (null); - Assert.Equal (0, added); - Assert.Equal (0, removed); - Assert.Equal (0, otherActions); - - for (int i = 0; i < 3; i++) - { - source1.Add ($"Item{i}"); - } - Assert.Equal (0, added); - Assert.Equal (0, removed); - Assert.Equal (0, otherActions); - - for (int i = 0; i < 3; i++) - { - source1.Remove (source1 [0]); - } - Assert.Equal (0, added); - Assert.Equal (0, removed); - Assert.Equal (0, otherActions); - Assert.Empty (source1); - } - - [Fact] - public void ListWrapper_CollectionChanged_Event_Is_Only_Subscribed_Once () - { - var added = 0; - var removed = 0; - var otherActions = 0; - ObservableCollection source1 = []; - ListWrapper lw = new (source1); - - lw.CollectionChanged += (sender, args) => - { - if (args.Action == NotifyCollectionChangedAction.Add) - { - added++; - } - else if (args.Action == NotifyCollectionChangedAction.Remove) - { - removed++; - } - else - { - otherActions++; - } - }; - - ObservableCollection source2 = []; - lw = new (source2); - ObservableCollection source3 = []; - lw = new (source3); - Assert.Equal (0, added); - Assert.Equal (0, removed); - Assert.Equal (0, otherActions); - - for (int i = 0; i < 3; i++) - { - source1.Add ($"Item{i}"); - source2.Add ($"Item{i}"); - source3.Add ($"Item{i}"); - } - - Assert.Equal (3, added); - Assert.Equal (0, removed); - Assert.Equal (0, otherActions); - - added = 0; - - for (int i = 0; i < 3; i++) - { - source1.Remove (source1 [0]); - source2.Remove (source2 [0]); - source3.Remove (source3 [0]); - } - Assert.Equal (0, added); - Assert.Equal (3, removed); - Assert.Equal (0, otherActions); - Assert.Empty (source1); - Assert.Empty (source2); - Assert.Empty (source3); - } - - [Fact] - public void ListWrapper_CollectionChanged_Event_UnSubscribe_Previous_Is_Disposed () - { - var added = 0; - var removed = 0; - var otherActions = 0; - ObservableCollection source1 = []; - ListWrapper lw = new (source1); - - lw.CollectionChanged += Lw_CollectionChanged; - - lw.Dispose (); - lw = new (null); - Assert.Equal (0, lw.Count); - Assert.Equal (0, added); - Assert.Equal (0, removed); - Assert.Equal (0, otherActions); - - for (int i = 0; i < 3; i++) - { - source1.Add ($"Item{i}"); - } - Assert.Equal (0, added); - Assert.Equal (0, removed); - Assert.Equal (0, otherActions); - - for (int i = 0; i < 3; i++) - { - source1.Remove (source1 [0]); - } - Assert.Equal (0, added); - Assert.Equal (0, removed); - Assert.Equal (0, otherActions); - Assert.Empty (source1); - - - void Lw_CollectionChanged (object sender, NotifyCollectionChangedEventArgs e) - { - if (e.Action == NotifyCollectionChangedAction.Add) - { - added++; - } - else if (e.Action == NotifyCollectionChangedAction.Remove) - { - removed++; - } - else - { - otherActions++; - } - } - } - - [Fact] - public void ListWrapper_SuspendCollectionChangedEvent_ResumeSuspendCollectionChangedEvent_Tests () - { - var added = 0; - ObservableCollection source = []; - ListWrapper lw = new (source); - - lw.CollectionChanged += Lw_CollectionChanged; - - lw.SuspendCollectionChangedEvent = true; - - for (int i = 0; i < 3; i++) - { - source.Add ($"Item{i}"); - } - Assert.Equal (0, added); - Assert.Equal (3, lw.Count); - Assert.Equal (3, source.Count); - - lw.SuspendCollectionChangedEvent = false; - - for (int i = 3; i < 6; i++) - { - source.Add ($"Item{i}"); - } - Assert.Equal (3, added); - Assert.Equal (6, lw.Count); - Assert.Equal (6, source.Count); - - - void Lw_CollectionChanged (object sender, NotifyCollectionChangedEventArgs e) - { - if (e.Action == NotifyCollectionChangedAction.Add) - { - added++; - } - } - } - - [Fact] - public void ListView_SuspendCollectionChangedEvent_ResumeSuspendCollectionChangedEvent_Tests () - { - var added = 0; - ObservableCollection source = []; - ListView lv = new ListView { Source = new ListWrapper (source) }; - - lv.CollectionChanged += Lw_CollectionChanged; - - lv.SuspendCollectionChangedEvent (); - - for (int i = 0; i < 3; i++) - { - source.Add ($"Item{i}"); - } - Assert.Equal (0, added); - Assert.Equal (3, lv.Source.Count); - Assert.Equal (3, source.Count); - - lv.ResumeSuspendCollectionChangedEvent (); - - for (int i = 3; i < 6; i++) - { - source.Add ($"Item{i}"); - } - Assert.Equal (3, added); - Assert.Equal (6, lv.Source.Count); - Assert.Equal (6, source.Count); - - - void Lw_CollectionChanged (object sender, NotifyCollectionChangedEventArgs e) - { - if (e.Action == NotifyCollectionChangedAction.Add) - { - added++; - } - } - } -} \ No newline at end of file diff --git a/Tests/UnitTestsParallelizable/Configuration/SourcesManagerTests.cs b/Tests/UnitTestsParallelizable/Configuration/SourcesManagerTests.cs index aa3017d518..1747883697 100644 --- a/Tests/UnitTestsParallelizable/Configuration/SourcesManagerTests.cs +++ b/Tests/UnitTestsParallelizable/Configuration/SourcesManagerTests.cs @@ -7,7 +7,7 @@ public class SourcesManagerTests #region Update (Stream) [Fact] - public void Update_WithNullSettingsScope_ReturnsFalse () + public void Load_WithNullSettingsScope_ReturnsFalse () { // Arrange var sourcesManager = new SourcesManager (); @@ -23,7 +23,7 @@ public void Update_WithNullSettingsScope_ReturnsFalse () } [Fact] - public void Update_WithValidStream_UpdatesSettingsScope () + public void Load_WithValidStream_UpdatesSettingsScope () { // Arrange var sourcesManager = new SourcesManager (); @@ -56,7 +56,7 @@ public void Update_WithValidStream_UpdatesSettingsScope () } [Fact] - public void Update_WithInvalidJson_AddsJsonError () + public void Load_WithInvalidJson_AddsJsonError () { // Arrange var sourcesManager = new SourcesManager (); @@ -86,7 +86,7 @@ public void Update_WithInvalidJson_AddsJsonError () #region Update (FilePath) [Fact] - public void Update_WithNonExistentFile_AddsToSourcesAndReturnsTrue () + public void Load_WithNonExistentFile_AddsToSourcesAndReturnsTrue () { // Arrange var sourcesManager = new SourcesManager (); @@ -104,7 +104,7 @@ public void Update_WithNonExistentFile_AddsToSourcesAndReturnsTrue () } [Fact] - public void Update_WithValidFile_UpdatesSettingsScope () + public void Load_WithValidFile_UpdatesSettingsScope () { // Arrange var sourcesManager = new SourcesManager (); @@ -140,7 +140,7 @@ public void Update_WithValidFile_UpdatesSettingsScope () } [Fact] - public void Update_WithIOException_RetriesAndFailsGracefully () + public void Load_WithIOException_RetriesAndFailsGracefully () { // Arrange var sourcesManager = new SourcesManager (); @@ -174,7 +174,7 @@ public void Update_WithIOException_RetriesAndFailsGracefully () #region Update (Json String) [Fact] - public void Update_WithNullOrEmptyJson_ReturnsFalse () + public void Load_WithNullOrEmptyJson_ReturnsFalse () { // Arrange var sourcesManager = new SourcesManager (); @@ -193,7 +193,7 @@ public void Update_WithNullOrEmptyJson_ReturnsFalse () } [Fact] - public void Update_WithValidJson_UpdatesSettingsScope () + public void Load_WithValidJson_UpdatesSettingsScope () { // Arrange var sourcesManager = new SourcesManager (); @@ -381,7 +381,7 @@ public void Sources_Dictionary_IsInitializedEmpty () } [Fact] - public void Update_WhenCalledMultipleTimes_MaintainsLastSourceForLocation () + public void Load_WhenCalledMultipleTimes_MaintainsLastSourceForLocation () { // Arrange var sourcesManager = new SourcesManager (); @@ -401,7 +401,7 @@ public void Update_WhenCalledMultipleTimes_MaintainsLastSourceForLocation () } [Fact] - public void Update_WithDifferentLocations_AddsAllSourcesToCollection () + public void Load_WithDifferentLocations_AddsAllSourcesToCollection () { // Arrange var sourcesManager = new SourcesManager (); @@ -452,7 +452,7 @@ public void Load_AddsResourceSourceToCollection () } [Fact] - public void Update_WithNonExistentFileAndDifferentLocations_TracksAllSources () + public void Load_WithNonExistentFileAndDifferentLocations_TracksAllSources () { // Arrange var sourcesManager = new SourcesManager (); diff --git a/Tests/UnitTestsParallelizable/Text/CollectionNavigatorTests.cs b/Tests/UnitTestsParallelizable/Text/CollectionNavigatorTests.cs index c3fce7af48..5d3923d50d 100644 --- a/Tests/UnitTestsParallelizable/Text/CollectionNavigatorTests.cs +++ b/Tests/UnitTestsParallelizable/Text/CollectionNavigatorTests.cs @@ -42,7 +42,7 @@ public void Cycling () // cycling with 'a' n = new CollectionNavigator (simpleStrings); - Assert.Equal (0, n.GetNextMatchingItem (-1, 'a')); + Assert.Equal (0, n.GetNextMatchingItem (null, 'a')); Assert.Equal (1, n.GetNextMatchingItem (0, 'a')); // if 4 (candle) is selected it should loop back to apricot @@ -53,7 +53,7 @@ public void Cycling () public void Delay () { var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); // No delay @@ -96,7 +96,7 @@ public void FullText () var strings = new [] { "apricot", "arm", "ta", "target", "text", "egg", "candle" }; var n = new CollectionNavigator (strings); - var current = 0; + int? current = 0; Assert.Equal (strings.IndexOf ("ta"), current = n.GetNextMatchingItem (current, 't')); // should match "te" in "text" @@ -137,7 +137,7 @@ public void IsCompatibleKey_Does_Not_Allow_Alt_And_Ctrl_Keys (KeyCode keyCode, b public void MinimizeMovement_False_ShouldMoveIfMultipleMatches () { var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot", "c", "car", "cart" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, "$$")); Assert.Equal (strings.IndexOf ("$100.00"), current = n.GetNextMatchingItem (current, "$")); @@ -166,14 +166,14 @@ public void MinimizeMovement_False_ShouldMoveIfMultipleMatches () Assert.Equal (strings.IndexOf ("car"), current = n.GetNextMatchingItem (current, "car")); Assert.Equal (strings.IndexOf ("cart"), current = n.GetNextMatchingItem (current, "car")); - Assert.Equal (-1, current = n.GetNextMatchingItem (current, "x")); + Assert.Null (n.GetNextMatchingItem (current, "x")); } [Fact] public void MinimizeMovement_True_ShouldStayOnCurrentIfMultipleMatches () { var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot", "c", "car", "cart" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, "$$", true)); Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, "$", true)); @@ -185,14 +185,14 @@ public void MinimizeMovement_True_ShouldStayOnCurrentIfMultipleMatches () Assert.Equal (strings.IndexOf ("car"), current = n.GetNextMatchingItem (current, "car", true)); Assert.Equal (strings.IndexOf ("car"), current = n.GetNextMatchingItem (current, "car", true)); - Assert.Equal (-1, current = n.GetNextMatchingItem (current, "x", true)); + Assert.Null (n.GetNextMatchingItem (current, "x", true)); } [Fact] public void MutliKeySearchPlusWrongKeyStays () { var strings = new [] { "a", "c", "can", "candle", "candy", "yellow", "zebra" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); // https://github.com/gui-cs/Terminal.Gui/pull/2132#issuecomment-1298425573 @@ -240,20 +240,20 @@ public void OutOfBoundsShouldBeIgnored () } [Fact] - public void ShouldAcceptNegativeOne () + public void ShouldAcceptNull () { var n = new CollectionNavigator (simpleStrings); - // Expect that index of -1 (i.e. no selection) should work correctly + // Expect that index of null (i.e. no selection) should work correctly // and select the first entry of the letter 'b' - Assert.Equal (2, n.GetNextMatchingItem (-1, 'b')); + Assert.Equal (2, n.GetNextMatchingItem (null, 'b')); } [Fact] public void Symbols () { var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); Assert.Equal (strings.IndexOf ("apricot"), current = n.GetNextMatchingItem (current, 'a')); Assert.Equal ("a", n.SearchString); @@ -293,7 +293,7 @@ public void Unicode () var strings = new [] { "apricot", "arm", "ta", "丗丙业丞", "丗丙丛", "text", "egg", "candle" }; var n = new CollectionNavigator (strings); - var current = 0; + int? current = 0; Assert.Equal (strings.IndexOf ("丗丙业丞"), current = n.GetNextMatchingItem (current, '丗')); // 丗丙业丞 is as good a match as 丗丙丛 @@ -319,7 +319,7 @@ public void Unicode () public void Word () { var strings = new [] { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); Assert.Equal (strings.IndexOf ("bat"), current = n.GetNextMatchingItem (current, 'b')); // match bat Assert.Equal (strings.IndexOf ("bat"), current = n.GetNextMatchingItem (current, 'a')); // match bat @@ -344,7 +344,7 @@ public void Word () public void CustomMatcher_NeverMatches () { var strings = new [] { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); var matchNone = new Mock (); diff --git a/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj b/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj index 35233cc037..5024415aaf 100644 --- a/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj +++ b/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj @@ -27,6 +27,11 @@ true + + + + + @@ -69,7 +74,4 @@ - - - \ No newline at end of file diff --git a/Tests/UnitTestsParallelizable/View/Draw/NeedsDrawTests.cs b/Tests/UnitTestsParallelizable/View/Draw/NeedsDrawTests.cs index b8b3e7d430..780ffb94ce 100644 --- a/Tests/UnitTestsParallelizable/View/Draw/NeedsDrawTests.cs +++ b/Tests/UnitTestsParallelizable/View/Draw/NeedsDrawTests.cs @@ -130,14 +130,14 @@ public void NeedsDraw_False_After_SetRelativeLayout_Absolute_Dims () Assert.False (view.NeedsLayout); // SRL won't change anything since the view frame wasn't changed - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.False (view.NeedsDraw); view.SetNeedsLayout (); // SRL won't change anything since the view frame wasn't changed // SRL doesn't depend on NeedsLayout, but LayoutSubViews does - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.False (view.NeedsDraw); Assert.True (view.NeedsLayout); @@ -180,7 +180,7 @@ public void NeedsDraw_False_After_SetRelativeLayout_Relative_Dims () Assert.True (view.NeedsDraw); Assert.True (superView.NeedsDraw); - superView.SetRelativeLayout (Application.Screen.Size); + superView.SetRelativeLayout (new (100, 100)); Assert.True (view.NeedsDraw); Assert.True (superView.NeedsDraw); } @@ -216,7 +216,7 @@ public void NeedsDraw_True_After_LayoutSubViews () view.EndInit (); Assert.True (view.NeedsDraw); - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.True (view.NeedsDraw); view.LayoutSubViews (); @@ -235,7 +235,7 @@ public void NeedsDraw_False_After_Draw () view.EndInit (); Assert.True (view.NeedsDraw); - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.True (view.NeedsDraw); view.LayoutSubViews (); diff --git a/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.PosTypes.cs b/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.PosTypes.cs index 0078736c5b..61aadaea74 100644 --- a/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.PosTypes.cs +++ b/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.PosTypes.cs @@ -597,7 +597,7 @@ public void With_Text_And_SubView_Using_PosAnchorEnd (int minWidth, int maxWidth // Without a subview, width should be 10 // Without a subview, height should be 1 - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.Equal (10, view.Frame.Width); Assert.Equal (1, view.Frame.Height); diff --git a/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.cs b/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.cs index b42b0bc9c8..4c1da89b56 100644 --- a/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.cs +++ b/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.cs @@ -304,10 +304,10 @@ public void TextFormatter_Settings_Change_View_Size () Width = Auto (), Height = 1 }; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); lastSize = view.Frame.Size; view.HotKeySpecifier = (Rune)'*'; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.NotEqual (lastSize, view.Frame.Size); view = new () @@ -316,10 +316,10 @@ public void TextFormatter_Settings_Change_View_Size () Width = Auto (), Height = 1 }; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); lastSize = view.Frame.Size; view.Text = "*ABCD"; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.NotEqual (lastSize, view.Frame.Size); } @@ -703,7 +703,7 @@ public void DimAutoStyle_Auto_JustText_Sizes_Correctly (string text, int expecte view.Text = text; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.Equal (new (expectedW, expectedH), view.Frame.Size); } @@ -812,7 +812,7 @@ public void DimAutoStyle_Text_Sizes_Correctly (string text, int expectedW, int e view.Text = text; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.Equal (new (expectedW, expectedH), view.Frame.Size); } @@ -831,7 +831,7 @@ public void DimAutoStyle_Text_Sizes_Correctly_With_Min (string text, int minWidt view.Text = text; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.Equal (new (expectedW, expectedH), view.Frame.Size); } diff --git a/Tests/UnitTestsParallelizable/View/Layout/FrameTests.cs b/Tests/UnitTestsParallelizable/View/Layout/FrameTests.cs index 364012cc39..1f9b24a90b 100644 --- a/Tests/UnitTestsParallelizable/View/Layout/FrameTests.cs +++ b/Tests/UnitTestsParallelizable/View/Layout/FrameTests.cs @@ -120,7 +120,7 @@ public void Frame_Empty_Initializer_Sets () Assert.True (view.NeedsLayout); view.Layout (); Assert.False (view.NeedsLayout); - Assert.Equal (Application.Screen, view.Frame); + Assert.Equal (new Size (2048, 2048), view.Frame.Size); view.Frame = Rectangle.Empty; Assert.Equal (Rectangle.Empty, view.Frame); @@ -165,7 +165,7 @@ public void Frame_Set () Assert.Equal (Rectangle.Empty, v.Frame); v.Dispose (); - v = new() { Frame = frame }; + v = new () { Frame = frame }; Assert.Equal (frame, v.Frame); v.Frame = newFrame; @@ -181,7 +181,7 @@ public void Frame_Set () Assert.Equal (Dim.Absolute (40), v.Height); v.Dispose (); - v = new() { X = frame.X, Y = frame.Y, Text = "v" }; + v = new () { X = frame.X, Y = frame.Y, Text = "v" }; v.Frame = newFrame; Assert.Equal (newFrame, v.Frame); @@ -196,7 +196,7 @@ public void Frame_Set () v.Dispose (); newFrame = new (10, 20, 30, 40); - v = new() { Frame = frame }; + v = new () { Frame = frame }; v.Frame = newFrame; Assert.Equal (newFrame, v.Frame); @@ -210,7 +210,7 @@ public void Frame_Set () Assert.Equal (Dim.Absolute (40), v.Height); v.Dispose (); - v = new() { X = frame.X, Y = frame.Y, Text = "v" }; + v = new () { X = frame.X, Y = frame.Y, Text = "v" }; v.Frame = newFrame; Assert.Equal (newFrame, v.Frame); diff --git a/Tests/UnitTestsParallelizable/View/SubviewTests.cs b/Tests/UnitTestsParallelizable/View/SubviewTests.cs index f02650d549..a447acd6e9 100644 --- a/Tests/UnitTestsParallelizable/View/SubviewTests.cs +++ b/Tests/UnitTestsParallelizable/View/SubviewTests.cs @@ -14,11 +14,11 @@ public void SuperViewChanged_Raised_On_Add () super.SuperViewChanged += (s, e) => { - superRaisedCount++; + superRaisedCount++; }; sub.SuperViewChanged += (s, e) => { - if (e.SuperView is {}) + if (e.SuperView is { }) { subRaisedCount++; } @@ -266,14 +266,14 @@ public void MoveSubViewTowardsEnd () superView.Add (subview1, subview2, subview3); superView.MoveSubViewTowardsEnd (subview2); - Assert.Equal (subview2, superView.SubViews.ToArray() [^1]); + Assert.Equal (subview2, superView.SubViews.ToArray () [^1]); superView.MoveSubViewTowardsEnd (subview1); - Assert.Equal (subview1, superView.SubViews.ToArray() [1]); + Assert.Equal (subview1, superView.SubViews.ToArray () [1]); // Already at end, what happens? superView.MoveSubViewTowardsEnd (subview2); - Assert.Equal (subview2, superView.SubViews.ToArray() [^1]); + Assert.Equal (subview2, superView.SubViews.ToArray () [^1]); } [Fact] @@ -517,7 +517,7 @@ public void Initialized_Event_Comparing_With_Added_Event () Assert.False (v2AddedToWin.CanFocus); Assert.False (svAddedTov1.CanFocus); - Application.LayoutAndDraw (); + top.Layout (); }; winAddedToTop.Initialized += (s, e) => diff --git a/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs b/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs new file mode 100644 index 0000000000..29faa1aab4 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs @@ -0,0 +1,513 @@ +#nullable enable +using System.Collections; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Text; +using Xunit.Abstractions; + +// ReSharper disable InconsistentNaming + +namespace UnitTests_Parallelizable.ViewTests; + +public class IListDataSourceTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + #region Concurrent Modification Tests + + [Fact] + public void ListWrapper_SuspendAndModify_NoEventsUntilResume () + { + ObservableCollection source = ["Item1"]; + ListWrapper wrapper = new (source); + var eventCount = 0; + + wrapper.CollectionChanged += (s, e) => eventCount++; + + wrapper.SuspendCollectionChangedEvent = true; + + source.Add ("Item2"); + source.Add ("Item3"); + source.RemoveAt (0); + + Assert.Equal (0, eventCount); + + wrapper.SuspendCollectionChangedEvent = false; + + // Should have adjusted marks for the removals that happened while suspended + Assert.Equal (2, wrapper.Count); + } + + #endregion + + /// + /// Test implementation of IListDataSource for testing custom implementations + /// + private class TestListDataSource : IListDataSource + { + private readonly List _items = ["Custom Item 00", "Custom Item 01", "Custom Item 02"]; + private readonly BitArray _marks = new (3); + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + public int Count => _items.Count; + + public int Length => _items.Any () ? _items.Max (s => s?.Length ?? 0) : 0; + + public bool SuspendCollectionChangedEvent { get; set; } + + public bool IsMarked (int item) + { + if (item < 0 || item >= _items.Count) + { + return false; + } + + return _marks [item]; + } + + public void SetMark (int item, bool value) + { + if (item >= 0 && item < _items.Count) + { + _marks [item] = value; + } + } + + public void Render (ListView listView, bool selected, int item, int col, int line, int width, int viewportX = 0) + { + if (item < 0 || item >= _items.Count) + { + return; + } + + listView.Move (col, line); + string text = _items [item] ?? ""; + + if (viewportX < text.Length) + { + text = text.Substring (viewportX); + } + else + { + text = ""; + } + + if (text.Length > width) + { + text = text.Substring (0, width); + } + + listView.AddStr (text); + + // Fill remaining width + for (int i = text.Length; i < width; i++) + { + listView.AddRune ((Rune)' '); + } + } + + public IList ToList () { return _items; } + + public void Dispose () { IsDisposed = true; } + + public void AddItem (string item) + { + _items.Add (item); + + // Resize marks + var newMarks = new BitArray (_items.Count); + + for (var i = 0; i < Math.Min (_marks.Length, newMarks.Length); i++) + { + newMarks [i] = _marks [i]; + } + + if (!SuspendCollectionChangedEvent) + { + CollectionChanged?.Invoke (this, new (NotifyCollectionChangedAction.Add, item, _items.Count - 1)); + } + } + + public bool IsDisposed { get; private set; } + } + + #region ListWrapper Render Tests + + [Fact] + public void ListWrapper_Render_NullItem_RendersEmpty () + { + ObservableCollection source = [null, "Item2"]; + ListWrapper wrapper = new (source); + var listView = new ListView { Width = 20, Height = 2 }; + listView.BeginInit (); + listView.EndInit (); + + // Render the null item (index 0) + wrapper.Render (listView, false, 0, 0, 0, 20); + + // Should not throw and should render empty/spaces + Assert.Equal (2, wrapper.Count); + } + + [Fact] + public void ListWrapper_Render_EmptyString_RendersSpaces () + { + ObservableCollection source = [""]; + ListWrapper wrapper = new (source); + var listView = new ListView { Width = 20, Height = 1 }; + listView.BeginInit (); + listView.EndInit (); + + wrapper.Render (listView, false, 0, 0, 0, 20); + + Assert.Equal (1, wrapper.Count); + Assert.Equal (0, wrapper.Length); // Empty string has zero length + } + + [Fact] + public void ListWrapper_Render_UnicodeText_CalculatesWidthCorrectly () + { + ObservableCollection source = ["Hello 你好", "Test"]; + ListWrapper wrapper = new (source); + + // "Hello 你好" should be: "Hello " (6) + "你" (2) + "好" (2) = 10 columns + Assert.True (wrapper.Length >= 10); + } + + [Fact] + public void ListWrapper_Render_LongString_ClipsToWidth () + { + var longString = new string ('X', 100); + ObservableCollection source = [longString]; + ListWrapper wrapper = new (source); + var listView = new ListView { Width = 20, Height = 1 }; + listView.BeginInit (); + listView.EndInit (); + + wrapper.Render (listView, false, 0, 0, 0, 10); + + Assert.Equal (100, wrapper.Length); + } + + [Fact] + public void ListWrapper_Render_WithViewportX_ScrollsHorizontally () + { + ObservableCollection source = ["0123456789ABCDEF"]; + ListWrapper wrapper = new (source); + var listView = new ListView { Width = 10, Height = 1 }; + listView.BeginInit (); + listView.EndInit (); + + // Render with horizontal scroll offset of 5 + wrapper.Render (listView, false, 0, 0, 0, 10, 5); + + // Should render "56789ABCDE" (starting at position 5) + Assert.Equal (16, wrapper.Length); + } + + [Fact] + public void ListWrapper_Render_ViewportXBeyondLength_RendersEmpty () + { + ObservableCollection source = ["Short"]; + ListWrapper wrapper = new (source); + var listView = new ListView { Width = 20, Height = 1 }; + listView.BeginInit (); + listView.EndInit (); + + // Render with viewport beyond string length + wrapper.Render (listView, false, 0, 0, 0, 10, 100); + + Assert.Equal (5, wrapper.Length); + } + + [Fact] + public void ListWrapper_Render_ColAndLine_PositionsCorrectly () + { + ObservableCollection source = ["Item1", "Item2"]; + ListWrapper wrapper = new (source); + var listView = new ListView { Width = 20, Height = 5 }; + listView.BeginInit (); + listView.EndInit (); + + // Render at different positions + wrapper.Render (listView, false, 0, 2, 1, 10); // col=2, line=1 + wrapper.Render (listView, false, 1, 0, 3, 10); // col=0, line=3 + + Assert.Equal (2, wrapper.Count); + } + + [Fact] + public void ListWrapper_Render_WidthConstraint_FillsRemaining () + { + ObservableCollection source = ["Hi"]; + ListWrapper wrapper = new (source); + var listView = new ListView { Width = 20, Height = 1 }; + listView.BeginInit (); + listView.EndInit (); + + // Render "Hi" in width of 10 - should fill remaining 8 with spaces + wrapper.Render (listView, false, 0, 0, 0, 10); + + Assert.Equal (2, wrapper.Length); + } + + [Fact] + public void ListWrapper_Render_NonStringType_UsesToString () + { + ObservableCollection source = [42, 100, -5]; + ListWrapper wrapper = new (source); + var listView = new ListView { Width = 20, Height = 3 }; + listView.BeginInit (); + listView.EndInit (); + + wrapper.Render (listView, false, 0, 0, 0, 10); + wrapper.Render (listView, false, 1, 0, 1, 10); + wrapper.Render (listView, false, 2, 0, 2, 10); + + Assert.Equal (3, wrapper.Count); + Assert.True (wrapper.Length >= 2); // "42" is 2 chars, "100" is 3 chars + } + + #endregion + + #region Custom IListDataSource Implementation Tests + + [Fact] + public void CustomDataSource_AllMembers_WorkCorrectly () + { + var customSource = new TestListDataSource (); + var listView = new ListView { Source = customSource, Width = 20, Height = 5 }; + + Assert.Equal (3, customSource.Count); + Assert.Equal (14, customSource.Length); // "Custom Item 00" is 14 chars + + // Test marking + Assert.False (customSource.IsMarked (0)); + customSource.SetMark (0, true); + Assert.True (customSource.IsMarked (0)); + customSource.SetMark (0, false); + Assert.False (customSource.IsMarked (0)); + + // Test ToList + IList list = customSource.ToList (); + Assert.Equal (3, list.Count); + Assert.Equal ("Custom Item 00", list [0]); + + // Test render doesn't throw + listView.BeginInit (); + listView.EndInit (); + Exception ex = Record.Exception (() => customSource.Render (listView, false, 0, 0, 0, 20)); + Assert.Null (ex); + } + + [Fact] + public void CustomDataSource_CollectionChanged_RaisedOnModification () + { + var customSource = new TestListDataSource (); + var eventRaised = false; + NotifyCollectionChangedAction? action = null; + + customSource.CollectionChanged += (s, e) => + { + eventRaised = true; + action = e.Action; + }; + + customSource.AddItem ("New Item"); + + Assert.True (eventRaised); + Assert.Equal (NotifyCollectionChangedAction.Add, action); + Assert.Equal (4, customSource.Count); + } + + [Fact] + public void CustomDataSource_SuspendCollectionChanged_SuppressesEvents () + { + var customSource = new TestListDataSource (); + var eventCount = 0; + + customSource.CollectionChanged += (s, e) => eventCount++; + + customSource.SuspendCollectionChangedEvent = true; + customSource.AddItem ("Item 1"); + customSource.AddItem ("Item 2"); + Assert.Equal (0, eventCount); // No events raised + + customSource.SuspendCollectionChangedEvent = false; + customSource.AddItem ("Item 3"); + Assert.Equal (1, eventCount); // Event raised after resume + } + + [Fact] + public void CustomDataSource_Dispose_CleansUp () + { + var customSource = new TestListDataSource (); + + customSource.Dispose (); + + // After dispose, adding should not raise events (if implemented correctly) + customSource.AddItem ("New Item"); + + // The test source doesn't unsubscribe in dispose, but this shows the pattern + Assert.True (customSource.IsDisposed); + } + + #endregion + + #region Edge Cases + + [Fact] + public void ListWrapper_EmptyCollection_PropertiesReturnZero () + { + ObservableCollection source = []; + ListWrapper wrapper = new (source); + + Assert.Equal (0, wrapper.Count); + Assert.Equal (0, wrapper.Length); + } + + [Fact] + public void ListWrapper_NullSource_HandledGracefully () + { + ListWrapper wrapper = new (null); + + Assert.Equal (0, wrapper.Count); + Assert.Equal (0, wrapper.Length); + + // ToList should not throw + IList list = wrapper.ToList (); + Assert.Empty (list); + } + + [Fact] + public void ListWrapper_IsMarked_OutOfBounds_ReturnsFalse () + { + ObservableCollection source = ["Item1"]; + ListWrapper wrapper = new (source); + + Assert.False (wrapper.IsMarked (-1)); + Assert.False (wrapper.IsMarked (1)); + Assert.False (wrapper.IsMarked (100)); + } + + [Fact] + public void ListWrapper_SetMark_OutOfBounds_DoesNotThrow () + { + ObservableCollection source = ["Item1"]; + ListWrapper wrapper = new (source); + + Exception ex = Record.Exception (() => wrapper.SetMark (-1, true)); + Assert.Null (ex); + + ex = Record.Exception (() => wrapper.SetMark (100, true)); + Assert.Null (ex); + } + + [Fact] + public void ListWrapper_CollectionShrinks_MarksAdjusted () + { + ObservableCollection source = ["Item1", "Item2", "Item3"]; + ListWrapper wrapper = new (source); + + wrapper.SetMark (0, true); + wrapper.SetMark (2, true); + + Assert.True (wrapper.IsMarked (0)); + Assert.True (wrapper.IsMarked (2)); + + // Remove item 1 (middle item) + source.RemoveAt (1); + + Assert.Equal (2, wrapper.Count); + Assert.True (wrapper.IsMarked (0)); // Still marked + + // Item that was at index 2 is now at index 1 + } + + [Fact] + public void ListWrapper_CollectionGrows_MarksPreserved () + { + ObservableCollection source = ["Item1"]; + ListWrapper wrapper = new (source); + + wrapper.SetMark (0, true); + Assert.True (wrapper.IsMarked (0)); + + source.Add ("Item2"); + source.Add ("Item3"); + + Assert.Equal (3, wrapper.Count); + Assert.True (wrapper.IsMarked (0)); // Original mark preserved + Assert.False (wrapper.IsMarked (1)); + Assert.False (wrapper.IsMarked (2)); + } + + [Fact] + public void ListWrapper_StartsWith_EmptyString_ReturnsFirst () + { + ObservableCollection source = ["Apple", "Banana", "Cherry"]; + ListWrapper wrapper = new (source); + + // Searching for empty string might return -1 or 0 depending on implementation + int result = wrapper.StartsWith (""); + Assert.True (result == -1 || result == 0); + } + + [Fact] + public void ListWrapper_StartsWith_NoMatch_ReturnsNegative () + { + ObservableCollection source = ["Apple", "Banana", "Cherry"]; + ListWrapper wrapper = new (source); + + int result = wrapper.StartsWith ("Zebra"); + Assert.Equal (-1, result); + } + + [Fact] + public void ListWrapper_StartsWith_CaseInsensitive () + { + ObservableCollection source = ["Apple", "Banana", "Cherry"]; + ListWrapper wrapper = new (source); + + Assert.Equal (0, wrapper.StartsWith ("app")); + Assert.Equal (0, wrapper.StartsWith ("APP")); + Assert.Equal (1, wrapper.StartsWith ("ban")); + Assert.Equal (1, wrapper.StartsWith ("BAN")); + } + + [Fact] + public void ListWrapper_MaxLength_UpdatesOnCollectionChange () + { + ObservableCollection source = ["Hi"]; + ListWrapper wrapper = new (source); + + Assert.Equal (2, wrapper.Length); + + source.Add ("Very Long String Indeed"); + Assert.Equal (23, wrapper.Length); + + source.Clear (); + source.Add ("X"); + Assert.Equal (1, wrapper.Length); + } + + [Fact] + public void ListWrapper_Dispose_UnsubscribesFromCollectionChanged () + { + ObservableCollection source = ["Item1"]; + ListWrapper wrapper = new (source); + + wrapper.CollectionChanged += (s, e) => { }; + + wrapper.Dispose (); + + // After dispose, source changes should not raise wrapper events + source.Add ("Item2"); + + // The wrapper's event might still fire, but the wrapper won't propagate source events + // This depends on implementation + } + + #endregion +} diff --git a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs index 9b942d6810..3634121658 100644 --- a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs @@ -1,32 +1,89 @@ -using System.Collections.ObjectModel; +#nullable enable +using System.Collections; +using System.Collections.ObjectModel; +using System.Collections.Specialized; using Moq; +using Terminal.Gui; +using UnitTests; +using Xunit; +using Xunit.Abstractions; + +// ReSharper disable AccessToModifiedClosure namespace UnitTests_Parallelizable.ViewsTests; -public class ListViewTests +public class ListViewTests (ITestOutputHelper output) { + private readonly ITestOutputHelper _output = output; + [Fact] + public void CollectionNavigatorMatcher_KeybindingsOverrideNavigator () + { + ObservableCollection source = ["apricot", "arm", "bat", "batman", "bates hotel", "candle"]; + var lv = new ListView { Source = new ListWrapper (source) }; + + lv.SetFocus (); + + lv.KeyBindings.Add (Key.B, Command.Down); + + Assert.Null (lv.SelectedItem); + + // Keys should be consumed to move down the navigation i.e. to apricot + Assert.True (lv.NewKeyDownEvent (Key.B)); + Assert.Equal (0, lv.SelectedItem); + + Assert.True (lv.NewKeyDownEvent (Key.B)); + Assert.Equal (1, lv.SelectedItem); + + // There is no keybinding for Key.C so it hits collection navigator i.e. we jump to candle + Assert.True (lv.NewKeyDownEvent (Key.C)); + Assert.Equal (5, lv.SelectedItem); + } + + [Fact] + public void ListView_CollectionNavigatorMatcher_KeybindingsOverrideNavigator () + { + ObservableCollection source = ["apricot", "arm", "bat", "batman", "bates hotel", "candle"]; + var lv = new ListView { Source = new ListWrapper (source) }; + + lv.SetFocus (); + + lv.KeyBindings.Add (Key.B, Command.Down); + + Assert.Null (lv.SelectedItem); + + // Keys should be consumed to move down the navigation i.e. to apricot + Assert.True (lv.NewKeyDownEvent (Key.B)); + Assert.Equal (0, lv.SelectedItem); + + Assert.True (lv.NewKeyDownEvent (Key.B)); + Assert.Equal (1, lv.SelectedItem); + + // There is no keybinding for Key.C so it hits collection navigator i.e. we jump to candle + Assert.True (lv.NewKeyDownEvent (Key.C)); + Assert.Equal (5, lv.SelectedItem); + } + [Fact] public void ListViewCollectionNavigatorMatcher_DefaultBehaviour () { - ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - ListView lv = new ListView { Source = new ListWrapper (source) }; + ObservableCollection source = ["apricot", "arm", "bat", "batman", "bates hotel", "candle"]; + var lv = new ListView { Source = new ListWrapper (source) }; // Keys are consumed during navigation Assert.True (lv.NewKeyDownEvent (Key.B)); Assert.True (lv.NewKeyDownEvent (Key.A)); Assert.True (lv.NewKeyDownEvent (Key.T)); - Assert.Equal ("bat", (string)lv.Source.ToList () [lv.SelectedItem]); + Assert.Equal ("bat", (string)lv.Source.ToList () [lv.SelectedItem!.Value]!); } [Fact] public void ListViewCollectionNavigatorMatcher_IgnoreKeys () { - ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - ListView lv = new ListView { Source = new ListWrapper (source) }; - + ObservableCollection source = ["apricot", "arm", "bat", "batman", "bates hotel", "candle"]; + var lv = new ListView { Source = new ListWrapper (source) }; - var matchNone = new Mock (); + Mock matchNone = new (); matchNone.Setup (m => m.IsCompatibleKey (It.IsAny ())) .Returns (false); @@ -45,11 +102,10 @@ public void ListViewCollectionNavigatorMatcher_IgnoreKeys () [Fact] public void ListViewCollectionNavigatorMatcher_OverrideMatching () { - ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - ListView lv = new ListView { Source = new ListWrapper (source) }; - + ObservableCollection source = ["apricot", "arm", "bat", "batman", "bates hotel", "candle"]; + var lv = new ListView { Source = new ListWrapper (source) }; - var matchNone = new Mock (); + Mock matchNone = new (); matchNone.Setup (m => m.IsCompatibleKey (It.IsAny ())) .Returns (true); @@ -59,6 +115,7 @@ public void ListViewCollectionNavigatorMatcher_OverrideMatching () .Returns ((string s, object key) => s.StartsWith ('B') && key?.ToString () == "candle"); lv.KeystrokeNavigator.Matcher = matchNone.Object; + // Keys are consumed during navigation Assert.True (lv.NewKeyDownEvent (Key.B)); Assert.Equal (5, lv.SelectedItem); @@ -67,54 +124,1461 @@ public void ListViewCollectionNavigatorMatcher_OverrideMatching () Assert.True (lv.NewKeyDownEvent (Key.T)); Assert.Equal (5, lv.SelectedItem); - Assert.Equal ("candle", (string)lv.Source.ToList () [lv.SelectedItem]); + Assert.Equal ("candle", (string)lv.Source.ToList () [lv.SelectedItem!.Value]!); } + #region ListView Tests (from ListViewTests.cs - parallelizable) + [Fact] - public void ListView_CollectionNavigatorMatcher_KeybindingsOverrideNavigator () + public void Constructors_Defaults () { - ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - ListView lv = new ListView { Source = new ListWrapper (source) }; + var lv = new ListView (); + Assert.Null (lv.Source); + Assert.True (lv.CanFocus); + Assert.Null (lv.SelectedItem); + Assert.False (lv.AllowsMultipleSelection); - lv.SetFocus (); + lv = new () { Source = new ListWrapper (["One", "Two", "Three"]) }; + Assert.NotNull (lv.Source); + Assert.Null (lv.SelectedItem); - lv.KeyBindings.Add (Key.B, Command.Down); + lv = new () { Source = new NewListDataSource () }; + Assert.NotNull (lv.Source); + Assert.Null (lv.SelectedItem); - Assert.Equal (-1, lv.SelectedItem); + lv = new () + { + Y = 1, Width = 10, Height = 20, Source = new ListWrapper (["One", "Two", "Three"]) + }; + Assert.NotNull (lv.Source); + Assert.Null (lv.SelectedItem); + Assert.Equal (new (0, 1, 10, 20), lv.Frame); - // Keys should be consumed to move down the navigation i.e. to apricot - Assert.True (lv.NewKeyDownEvent (Key.B)); + lv = new () { Y = 1, Width = 10, Height = 20, Source = new NewListDataSource () }; + Assert.NotNull (lv.Source); + Assert.Null (lv.SelectedItem); + Assert.Equal (new (0, 1, 10, 20), lv.Frame); + } + + private class NewListDataSource : IListDataSource + { +#pragma warning disable CS0067 + public event NotifyCollectionChangedEventHandler? CollectionChanged; +#pragma warning restore CS0067 + + public int Count => 0; + public int Length => 0; + + public bool SuspendCollectionChangedEvent + { + get => throw new NotImplementedException (); + set => throw new NotImplementedException (); + } + + public bool IsMarked (int item) { throw new NotImplementedException (); } + + public void Render ( + ListView container, + bool selected, + int item, + int col, + int line, + int width, + int viewportX = 0 + ) + { + throw new NotImplementedException (); + } + + public void SetMark (int item, bool value) { throw new NotImplementedException (); } + public IList ToList () { return new List { "One", "Two", "Three" }; } + + public void Dispose () { throw new NotImplementedException (); } + } + + [Fact] + public void KeyBindings_Command () + { + ObservableCollection source = ["One", "Two", "Three"]; + var lv = new ListView { Height = 2, AllowsMarking = true, Source = new ListWrapper (source) }; + lv.BeginInit (); + lv.EndInit (); + Assert.Null (lv.SelectedItem); + Assert.True (lv.NewKeyDownEvent (Key.CursorDown)); + Assert.Equal (0, lv.SelectedItem); + Assert.True (lv.NewKeyDownEvent (Key.CursorUp)); + Assert.Equal (0, lv.SelectedItem); + Assert.True (lv.NewKeyDownEvent (Key.PageDown)); + Assert.Equal (2, lv.SelectedItem); + Assert.Equal (2, lv.TopItem); + Assert.True (lv.NewKeyDownEvent (Key.PageUp)); Assert.Equal (0, lv.SelectedItem); + Assert.Equal (0, lv.TopItem); + Assert.False (lv.Source.IsMarked (lv.SelectedItem!.Value)); + Assert.True (lv.NewKeyDownEvent (Key.Space)); + Assert.True (lv.Source.IsMarked (lv.SelectedItem!.Value)); + var opened = false; + lv.OpenSelectedItem += (s, _) => opened = true; + Assert.True (lv.NewKeyDownEvent (Key.Enter)); + Assert.True (opened); + Assert.True (lv.NewKeyDownEvent (Key.End)); + Assert.Equal (2, lv.SelectedItem); + Assert.True (lv.NewKeyDownEvent (Key.Home)); + Assert.Equal (0, lv.SelectedItem); + } - Assert.True (lv.NewKeyDownEvent (Key.B)); + [Fact] + public void HotKey_Command_SetsFocus () + { + var view = new ListView (); + + view.CanFocus = true; + Assert.False (view.HasFocus); + view.InvokeCommand (Command.HotKey); + Assert.True (view.HasFocus); + } + + [Fact] + public void HotKey_Command_Does_Not_Accept () + { + var listView = new ListView (); + var accepted = false; + + listView.Accepting += OnAccepted; + listView.InvokeCommand (Command.HotKey); + + Assert.False (accepted); + + return; + + void OnAccepted (object? sender, CommandEventArgs e) { accepted = true; } + } + + [Fact] + public void Accept_Command_Accepts_and_Opens_Selected_Item () + { + ObservableCollection source = ["One", "Two", "Three"]; + var listView = new ListView { Source = new ListWrapper (source) }; + listView.SelectedItem = 0; + + var accepted = false; + var opened = false; + var selectedValue = string.Empty; + + listView.Accepting += Accepted; + listView.OpenSelectedItem += OpenSelectedItem; + + listView.InvokeCommand (Command.Accept); + + Assert.True (accepted); + Assert.True (opened); + Assert.Equal (source [0], selectedValue); + + return; + + void OpenSelectedItem (object? sender, ListViewItemEventArgs e) + { + opened = true; + selectedValue = e.Value!.ToString (); + } + + void Accepted (object? sender, CommandEventArgs e) { accepted = true; } + } + + [Fact] + public void Accept_Cancel_Event_Prevents_OpenSelectedItem () + { + ObservableCollection source = ["One", "Two", "Three"]; + var listView = new ListView { Source = new ListWrapper (source) }; + listView.SelectedItem = 0; + + var accepted = false; + var opened = false; + var selectedValue = string.Empty; + + listView.Accepting += Accepted; + listView.OpenSelectedItem += OpenSelectedItem; + + listView.InvokeCommand (Command.Accept); + + Assert.True (accepted); + Assert.False (opened); + Assert.Equal (string.Empty, selectedValue); + + return; + + void OpenSelectedItem (object? sender, ListViewItemEventArgs e) + { + opened = true; + selectedValue = e.Value!.ToString (); + } + + void Accepted (object? sender, CommandEventArgs e) + { + accepted = true; + e.Handled = true; + } + } + + [Fact] + public void ListViewProcessKeyReturnValue_WithMultipleCommands () + { + var lv = new ListView { Source = new ListWrapper (["One", "Two", "Three", "Four"]) }; + + Assert.NotNull (lv.Source); + + // first item should be deselected by default + Assert.Null (lv.SelectedItem); + + // bind shift down to move down twice in control + lv.KeyBindings.Add (Key.CursorDown.WithShift, Command.Down, Command.Down); + + Key ev = Key.CursorDown.WithShift; + + Assert.True (lv.NewKeyDownEvent (ev), "The first time we move down 2 it should be possible"); + + // After moving down twice from null we should be at 'Two' Assert.Equal (1, lv.SelectedItem); - // There is no keybinding for Key.C so it hits collection navigator i.e. we jump to candle - Assert.True (lv.NewKeyDownEvent (Key.C)); - Assert.Equal (5, lv.SelectedItem); + // clear the items + lv.SetSource (null); + + // Press key combo again - return should be false this time as none of the Commands are allowable + Assert.False (lv.NewKeyDownEvent (ev), "We cannot move down so will not respond to this"); } [Fact] - public void CollectionNavigatorMatcher_KeybindingsOverrideNavigator () + public void AllowsMarking_True_SpaceWithShift_SelectsThenDown_SingleSelection () { - ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - ListView lv = new ListView { Source = new ListWrapper (source) }; + var lv = new ListView { Source = new ListWrapper (["One", "Two", "Three"]) }; + lv.AllowsMarking = true; + lv.AllowsMultipleSelection = false; - lv.SetFocus (); + Assert.NotNull (lv.Source); - lv.KeyBindings.Add (Key.B, Command.Down); + // first item should be deselected by default + Assert.Null (lv.SelectedItem); - Assert.Equal (-1, lv.SelectedItem); + // nothing is ticked + Assert.False (lv.Source.IsMarked (0)); + Assert.False (lv.Source.IsMarked (1)); + Assert.False (lv.Source.IsMarked (2)); - // Keys should be consumed to move down the navigation i.e. to apricot - Assert.True (lv.NewKeyDownEvent (Key.B)); + // view should indicate that it has accepted and consumed the event + Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); + + // first item should now be selected Assert.Equal (0, lv.SelectedItem); - Assert.True (lv.NewKeyDownEvent (Key.B)); + // none of the items should be ticked + Assert.False (lv.Source.IsMarked (0)); + Assert.False (lv.Source.IsMarked (1)); + Assert.False (lv.Source.IsMarked (2)); + + // Press key combo again + Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); + + // second item should now be selected Assert.Equal (1, lv.SelectedItem); - // There is no keybinding for Key.C so it hits collection navigator i.e. we jump to candle - Assert.True (lv.NewKeyDownEvent (Key.C)); - Assert.Equal (5, lv.SelectedItem); + // first item only should be ticked + Assert.True (lv.Source.IsMarked (0)); + Assert.False (lv.Source.IsMarked (1)); + Assert.False (lv.Source.IsMarked (2)); + + // Press key combo again + Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); + Assert.Equal (2, lv.SelectedItem); + Assert.False (lv.Source.IsMarked (0)); + Assert.True (lv.Source.IsMarked (1)); + Assert.False (lv.Source.IsMarked (2)); + + // Press key combo again + Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); + Assert.Equal (2, lv.SelectedItem); // cannot move down any further + Assert.False (lv.Source.IsMarked (0)); + Assert.False (lv.Source.IsMarked (1)); + Assert.True (lv.Source.IsMarked (2)); // but can toggle marked + + // Press key combo again + Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); + Assert.Equal (2, lv.SelectedItem); // cannot move down any further + Assert.False (lv.Source.IsMarked (0)); + Assert.False (lv.Source.IsMarked (1)); + Assert.False (lv.Source.IsMarked (2)); // untoggle toggle marked + } + + [Fact] + public void AllowsMarking_True_SpaceWithShift_SelectsThenDown_MultipleSelection () + { + var lv = new ListView { Source = new ListWrapper (["One", "Two", "Three"]) }; + lv.AllowsMarking = true; + lv.AllowsMultipleSelection = true; + + Assert.NotNull (lv.Source); + + // first item should be deselected by default + Assert.Null (lv.SelectedItem); + + // nothing is ticked + Assert.False (lv.Source.IsMarked (0)); + Assert.False (lv.Source.IsMarked (1)); + Assert.False (lv.Source.IsMarked (2)); + + // view should indicate that it has accepted and consumed the event + Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); + + // first item should now be selected + Assert.Equal (0, lv.SelectedItem); + + // none of the items should be ticked + Assert.False (lv.Source.IsMarked (0)); + Assert.False (lv.Source.IsMarked (1)); + Assert.False (lv.Source.IsMarked (2)); + + // Press key combo again + Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); + + // second item should now be selected + Assert.Equal (1, lv.SelectedItem); + + // first item only should be ticked + Assert.True (lv.Source.IsMarked (0)); + Assert.False (lv.Source.IsMarked (1)); + Assert.False (lv.Source.IsMarked (2)); + + // Press key combo again + Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); + Assert.Equal (2, lv.SelectedItem); + Assert.True (lv.Source.IsMarked (0)); + Assert.True (lv.Source.IsMarked (1)); + Assert.False (lv.Source.IsMarked (2)); + + // Press key combo again + Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); + Assert.Equal (2, lv.SelectedItem); // cannot move down any further + Assert.True (lv.Source.IsMarked (0)); + Assert.True (lv.Source.IsMarked (1)); + Assert.True (lv.Source.IsMarked (2)); // but can toggle marked + + // Press key combo again + Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); + Assert.Equal (2, lv.SelectedItem); // cannot move down any further + Assert.True (lv.Source.IsMarked (0)); + Assert.True (lv.Source.IsMarked (1)); + Assert.False (lv.Source.IsMarked (2)); // untoggle toggle marked + } + + [Fact] + public void ListWrapper_StartsWith () + { + ListWrapper lw = new (["One", "Two", "Three"]); + + Assert.Equal (1, lw.StartsWith ("t")); + Assert.Equal (1, lw.StartsWith ("tw")); + Assert.Equal (2, lw.StartsWith ("th")); + Assert.Equal (1, lw.StartsWith ("T")); + Assert.Equal (1, lw.StartsWith ("TW")); + Assert.Equal (2, lw.StartsWith ("TH")); + + lw = new (["One", "Two", "Three"]); + + Assert.Equal (1, lw.StartsWith ("t")); + Assert.Equal (1, lw.StartsWith ("tw")); + Assert.Equal (2, lw.StartsWith ("th")); + Assert.Equal (1, lw.StartsWith ("T")); + Assert.Equal (1, lw.StartsWith ("TW")); + Assert.Equal (2, lw.StartsWith ("TH")); + } + + [Fact] + public void OnEnter_Does_Not_Throw_Exception () + { + var lv = new ListView (); + var top = new View (); + top.Add (lv); + Exception exception = Record.Exception (() => lv.SetFocus ()); + Assert.Null (exception); + } + + [Fact] + public void SelectedItem_Get_Set () + { + var lv = new ListView { Source = new ListWrapper (["One", "Two", "Three"]) }; + Assert.Null (lv.SelectedItem); + Assert.Throws (() => lv.SelectedItem = 3); + Exception exception = Record.Exception (() => lv.SelectedItem = null); + Assert.Null (exception); + } + + [Fact] + public void SetSource_Preserves_ListWrapper_Instance_If_Not_Null () + { + var lv = new ListView { Source = new ListWrapper (["One", "Two"]) }; + + Assert.NotNull (lv.Source); + + lv.SetSource (null); + Assert.NotNull (lv.Source); + + lv.Source = null; + Assert.Null (lv.Source); + + lv = new () { Source = new ListWrapper (["One", "Two"]) }; + Assert.NotNull (lv.Source); + + lv.SetSourceAsync (null); + Assert.NotNull (lv.Source); + } + + [Fact] + public void SettingEmptyKeybindingThrows () + { + var lv = new ListView { Source = new ListWrapper (["One", "Two", "Three"]) }; + Assert.Throws (() => lv.KeyBindings.Add (Key.Space)); + } + + [Fact] + public void CollectionChanged_Event () + { + var added = 0; + var removed = 0; + ObservableCollection source = []; + var lv = new ListView { Source = new ListWrapper (source) }; + + lv.CollectionChanged += (sender, args) => + { + if (args.Action == NotifyCollectionChangedAction.Add) + { + added++; + } + else if (args.Action == NotifyCollectionChangedAction.Remove) + { + removed++; + } + }; + + for (var i = 0; i < 3; i++) + { + source.Add ($"Item{i}"); + } + + Assert.Equal (3, added); + Assert.Equal (0, removed); + + added = 0; + + for (var i = 0; i < 3; i++) + { + source.Remove (source [0]); + } + + Assert.Equal (0, added); + Assert.Equal (3, removed); + Assert.Empty (source); + } + + [Fact] + public void CollectionChanged_Event_Is_Only_Subscribed_Once () + { + var added = 0; + var removed = 0; + var otherActions = 0; + IList source1 = []; + var lv = new ListView { Source = new ListWrapper (new (source1)) }; + + lv.CollectionChanged += (sender, args) => + { + if (args.Action == NotifyCollectionChangedAction.Add) + { + added++; + } + else if (args.Action == NotifyCollectionChangedAction.Remove) + { + removed++; + } + else + { + otherActions++; + } + }; + + ObservableCollection source2 = []; + lv.Source = new ListWrapper (source2); + ObservableCollection source3 = []; + lv.Source = new ListWrapper (source3); + Assert.Equal (0, added); + Assert.Equal (0, removed); + Assert.Equal (0, otherActions); + + for (var i = 0; i < 3; i++) + { + source1.Add ($"Item{i}"); + source2.Add ($"Item{i}"); + source3.Add ($"Item{i}"); + } + + Assert.Equal (3, added); + Assert.Equal (0, removed); + Assert.Equal (0, otherActions); + + added = 0; + + for (var i = 0; i < 3; i++) + { + source1.Remove (source1 [0]); + source2.Remove (source2 [0]); + source3.Remove (source3 [0]); + } + + Assert.Equal (0, added); + Assert.Equal (3, removed); + Assert.Equal (0, otherActions); + Assert.Empty (source1); + Assert.Empty (source2); + Assert.Empty (source3); + } + + [Fact] + public void CollectionChanged_Event_UnSubscribe_Previous_If_New_Is_Null () + { + var added = 0; + var removed = 0; + var otherActions = 0; + ObservableCollection source1 = []; + var lv = new ListView { Source = new ListWrapper (source1) }; + + lv.CollectionChanged += (sender, args) => + { + if (args.Action == NotifyCollectionChangedAction.Add) + { + added++; + } + else if (args.Action == NotifyCollectionChangedAction.Remove) + { + removed++; + } + else + { + otherActions++; + } + }; + + lv.Source = new ListWrapper (null); + Assert.Equal (0, added); + Assert.Equal (0, removed); + Assert.Equal (0, otherActions); + + for (var i = 0; i < 3; i++) + { + source1.Add ($"Item{i}"); + } + + Assert.Equal (0, added); + Assert.Equal (0, removed); + Assert.Equal (0, otherActions); + + for (var i = 0; i < 3; i++) + { + source1.Remove (source1 [0]); + } + + Assert.Equal (0, added); + Assert.Equal (0, removed); + Assert.Equal (0, otherActions); + Assert.Empty (source1); + } + + [Fact] + public void ListWrapper_CollectionChanged_Event_Is_Only_Subscribed_Once () + { + var added = 0; + var removed = 0; + var otherActions = 0; + ObservableCollection source1 = []; + ListWrapper lw = new (source1); + + lw.CollectionChanged += (sender, args) => + { + if (args.Action == NotifyCollectionChangedAction.Add) + { + added++; + } + else if (args.Action == NotifyCollectionChangedAction.Remove) + { + removed++; + } + else + { + otherActions++; + } + }; + + ObservableCollection source2 = []; + lw = new (source2); + ObservableCollection source3 = []; + lw = new (source3); + Assert.Equal (0, added); + Assert.Equal (0, removed); + Assert.Equal (0, otherActions); + + for (var i = 0; i < 3; i++) + { + source1.Add ($"Item{i}"); + source2.Add ($"Item{i}"); + source3.Add ($"Item{i}"); + } + + Assert.Equal (3, added); + Assert.Equal (0, removed); + Assert.Equal (0, otherActions); + + added = 0; + + for (var i = 0; i < 3; i++) + { + source1.Remove (source1 [0]); + source2.Remove (source2 [0]); + source3.Remove (source3 [0]); + } + + Assert.Equal (0, added); + Assert.Equal (3, removed); + Assert.Equal (0, otherActions); + Assert.Empty (source1); + Assert.Empty (source2); + Assert.Empty (source3); + } + + [Fact] + public void ListWrapper_CollectionChanged_Event_UnSubscribe_Previous_Is_Disposed () + { + var added = 0; + var removed = 0; + var otherActions = 0; + ObservableCollection source1 = []; + ListWrapper lw = new (source1); + + lw.CollectionChanged += Lw_CollectionChanged; + + lw.Dispose (); + lw = new (null); + Assert.Equal (0, lw.Count); + Assert.Equal (0, added); + Assert.Equal (0, removed); + Assert.Equal (0, otherActions); + + for (var i = 0; i < 3; i++) + { + source1.Add ($"Item{i}"); + } + + Assert.Equal (0, added); + Assert.Equal (0, removed); + Assert.Equal (0, otherActions); + + for (var i = 0; i < 3; i++) + { + source1.Remove (source1 [0]); + } + + Assert.Equal (0, added); + Assert.Equal (0, removed); + Assert.Equal (0, otherActions); + Assert.Empty (source1); + + void Lw_CollectionChanged (object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add) + { + added++; + } + } + } + + [Fact] + public void ListWrapper_SuspendCollectionChangedEvent_ResumeSuspendCollectionChangedEvent_Tests () + { + var added = 0; + ObservableCollection source = []; + ListWrapper lw = new (source); + + lw.CollectionChanged += Lw_CollectionChanged; + + lw.SuspendCollectionChangedEvent = true; + + for (var i = 0; i < 3; i++) + { + source.Add ($"Item{i}"); + } + + Assert.Equal (0, added); + Assert.Equal (3, lw.Count); + Assert.Equal (3, source.Count); + + lw.SuspendCollectionChangedEvent = false; + + for (var i = 3; i < 6; i++) + { + source.Add ($"Item{i}"); + } + + Assert.Equal (3, added); + Assert.Equal (6, lw.Count); + Assert.Equal (6, source.Count); + + void Lw_CollectionChanged (object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add) + { + added++; + } + } + } + + [Fact] + public void ListView_SuspendCollectionChangedEvent_ResumeSuspendCollectionChangedEvent_Tests () + { + var added = 0; + ObservableCollection source = []; + var lv = new ListView { Source = new ListWrapper (source) }; + + lv.CollectionChanged += Lw_CollectionChanged; + + lv.SuspendCollectionChangedEvent (); + + for (var i = 0; i < 3; i++) + { + source.Add ($"Item{i}"); + } + + Assert.Equal (0, added); + Assert.Equal (3, lv.Source.Count); + Assert.Equal (3, source.Count); + + lv.ResumeSuspendCollectionChangedEvent (); + + for (var i = 3; i < 6; i++) + { + source.Add ($"Item{i}"); + } + + Assert.Equal (3, added); + Assert.Equal (6, lv.Source.Count); + Assert.Equal (6, source.Count); + + void Lw_CollectionChanged (object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add) + { + added++; + } + } + } + + #endregion + + [Fact] + public void Clicking_On_Border_Is_Ignored () + { + IApplication? app = Application.Create (); + app.Init ("fake"); + + var selected = ""; + + var lv = new ListView + { + Height = 5, + Width = 7, + BorderStyle = LineStyle.Single + }; + lv.SetSource (["One", "Two", "Three", "Four"]); + lv.SelectedItemChanged += (s, e) => selected = e.Value.ToString (); + var top = new Toplevel (); + top.Add (lv); + app.Begin (top); + + //AutoInitShutdownAttribute.RunIteration (); + + Assert.Equal (new (1), lv.Border!.Thickness); + Assert.Null (lv.SelectedItem); + Assert.Equal ("", lv.Text); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +┌─────┐ +│One │ +│Two │ +│Three│ +└─────┘", + _output, app?.Driver); + + app?.Mouse.RaiseMouseEvent (new () { ScreenPosition = new (0, 0), Flags = MouseFlags.Button1Clicked }); + Assert.Equal ("", selected); + Assert.Null (lv.SelectedItem); + + app?.Mouse.RaiseMouseEvent ( + new () + { + ScreenPosition = new (1, 1), Flags = MouseFlags.Button1Clicked + }); + Assert.Equal ("One", selected); + Assert.Equal (0, lv.SelectedItem); + + app?.Mouse.RaiseMouseEvent ( + new () + { + ScreenPosition = new (1, 2), Flags = MouseFlags.Button1Clicked + }); + Assert.Equal ("Two", selected); + Assert.Equal (1, lv.SelectedItem); + + app?.Mouse.RaiseMouseEvent ( + new () + { + ScreenPosition = new (1, 3), Flags = MouseFlags.Button1Clicked + }); + Assert.Equal ("Three", selected); + Assert.Equal (2, lv.SelectedItem); + + app?.Mouse.RaiseMouseEvent ( + new () + { + ScreenPosition = new (1, 4), Flags = MouseFlags.Button1Clicked + }); + Assert.Equal ("Three", selected); + Assert.Equal (2, lv.SelectedItem); + top.Dispose (); + + app.Shutdown (); + } + + [Fact] + public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () + { + IApplication? app = Application.Create (); + app.Init ("fake"); + app.Driver?.SetScreenSize (12, 12); + + ObservableCollection source = []; + + for (var i = 0; i < 20; i++) + { + source.Add ($"Line{i}"); + } + + var lv = new ListView { Width = Dim.Fill (), Height = Dim.Fill (), Source = new ListWrapper (source) }; + var win = new Window (); + win.Add (lv); + var top = new Toplevel (); + top.Add (win); + app.Begin (top); + + Assert.Null (lv.SelectedItem); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +┌──────────┐ +│Line0 │ +│Line1 │ +│Line2 │ +│Line3 │ +│Line4 │ +│Line5 │ +│Line6 │ +│Line7 │ +│Line8 │ +│Line9 │ +└──────────┘", + _output, app.Driver + ); + + Assert.True (lv.ScrollVertical (10)); + app.LayoutAndDraw (); + Assert.Null (lv.SelectedItem); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +┌──────────┐ +│Line10 │ +│Line11 │ +│Line12 │ +│Line13 │ +│Line14 │ +│Line15 │ +│Line16 │ +│Line17 │ +│Line18 │ +│Line19 │ +└──────────┘", + _output, app.Driver + ); + + Assert.True (lv.MoveDown ()); + app.LayoutAndDraw (); + Assert.Equal (0, lv.SelectedItem); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +┌──────────┐ +│Line0 │ +│Line1 │ +│Line2 │ +│Line3 │ +│Line4 │ +│Line5 │ +│Line6 │ +│Line7 │ +│Line8 │ +│Line9 │ +└──────────┘", + _output, app.Driver + ); + + Assert.True (lv.MoveEnd ()); + app.LayoutAndDraw (); + Assert.Equal (19, lv.SelectedItem); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +┌──────────┐ +│Line10 │ +│Line11 │ +│Line12 │ +│Line13 │ +│Line14 │ +│Line15 │ +│Line16 │ +│Line17 │ +│Line18 │ +│Line19 │ +└──────────┘", + _output, app.Driver + ); + + Assert.True (lv.ScrollVertical (-20)); + app.LayoutAndDraw (); + Assert.Equal (19, lv.SelectedItem); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +┌──────────┐ +│Line0 │ +│Line1 │ +│Line2 │ +│Line3 │ +│Line4 │ +│Line5 │ +│Line6 │ +│Line7 │ +│Line8 │ +│Line9 │ +└──────────┘", + _output, app.Driver + ); + + Assert.True (lv.MoveDown ()); + app.LayoutAndDraw (); + Assert.Equal (19, lv.SelectedItem); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +┌──────────┐ +│Line10 │ +│Line11 │ +│Line12 │ +│Line13 │ +│Line14 │ +│Line15 │ +│Line16 │ +│Line17 │ +│Line18 │ +│Line19 │ +└──────────┘", + _output, app.Driver + ); + + Assert.True (lv.ScrollVertical (-20)); + app.LayoutAndDraw (); + Assert.Equal (19, lv.SelectedItem); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +┌──────────┐ +│Line0 │ +│Line1 │ +│Line2 │ +│Line3 │ +│Line4 │ +│Line5 │ +│Line6 │ +│Line7 │ +│Line8 │ +│Line9 │ +└──────────┘", + _output, app.Driver + ); + + Assert.True (lv.MoveDown ()); + app.LayoutAndDraw (); + Assert.Equal (19, lv.SelectedItem); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +┌──────────┐ +│Line10 │ +│Line11 │ +│Line12 │ +│Line13 │ +│Line14 │ +│Line15 │ +│Line16 │ +│Line17 │ +│Line18 │ +│Line19 │ +└──────────┘", + _output, app.Driver + ); + + Assert.True (lv.MoveHome ()); + app.LayoutAndDraw (); + Assert.Equal (0, lv.SelectedItem); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +┌──────────┐ +│Line0 │ +│Line1 │ +│Line2 │ +│Line3 │ +│Line4 │ +│Line5 │ +│Line6 │ +│Line7 │ +│Line8 │ +│Line9 │ +└──────────┘", + _output, app.Driver + ); + + Assert.True (lv.ScrollVertical (20)); + app.LayoutAndDraw (); + Assert.Equal (0, lv.SelectedItem); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +┌──────────┐ +│Line19 │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────┘", + _output, app.Driver + ); + + Assert.True (lv.MoveUp ()); + app.LayoutAndDraw (); + Assert.Equal (0, lv.SelectedItem); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +┌──────────┐ +│Line0 │ +│Line1 │ +│Line2 │ +│Line3 │ +│Line4 │ +│Line5 │ +│Line6 │ +│Line7 │ +│Line8 │ +│Line9 │ +└──────────┘", + _output, app.Driver + ); + top.Dispose (); + app.Shutdown (); + } + + [Fact] + public void EnsureSelectedItemVisible_SelectedItem () + { + IApplication? app = Application.Create (); + app.Init ("fake"); + app.Driver?.SetScreenSize (12, 12); + + ObservableCollection source = []; + + for (var i = 0; i < 10; i++) + { + source.Add ($"Item {i}"); + } + + var lv = new ListView { Width = 10, Height = 5, Source = new ListWrapper (source) }; + var top = new Toplevel (); + top.Add (lv); + app.Begin (top); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +Item 0 +Item 1 +Item 2 +Item 3 +Item 4", + _output, app.Driver + ); + + // EnsureSelectedItemVisible is auto enabled on the OnSelectedChanged + lv.SelectedItem = 6; + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +Item 2 +Item 3 +Item 4 +Item 5 +Item 6", + _output, app.Driver + ); + top.Dispose (); + app.Shutdown (); + } + + [Fact] + public void EnsureSelectedItemVisible_Top () + { + IApplication? app = Application.Create (); + app.Init ("fake"); + IDriver? driver = app.Driver; + driver.SetScreenSize (8, 2); + + ObservableCollection source = ["First", "Second"]; + var lv = new ListView { Width = Dim.Fill (), Height = 1, Source = new ListWrapper (source) }; + lv.SelectedItem = 1; + var top = new Toplevel (); + top.Add (lv); + app.Begin (top); + + Assert.Equal ("Second ", GetContents (0)); + Assert.Equal (new (' ', 7), GetContents (1)); + + lv.MoveUp (); + lv.Draw (); + + Assert.Equal ("First ", GetContents (0)); + Assert.Equal (new (' ', 7), GetContents (1)); + + string GetContents (int line) + { + var item = ""; + + for (var i = 0; i < 7; i++) + { + item += app.Driver?.Contents [line, i].Rune; + } + + return item; + } + + top.Dispose (); + app.Shutdown (); + } + + [Fact] + public void LeftItem_TopItem_Tests () + { + IApplication? app = Application.Create (); + app.Init ("fake"); + app.Driver?.SetScreenSize (12, 12); + + ObservableCollection source = []; + + for (var i = 0; i < 5; i++) + { + source.Add ($"Item {i}"); + } + + var lv = new ListView + { + X = 1, + Source = new ListWrapper (source) + }; + lv.Height = lv.Source.Count; + lv.Width = lv.MaxLength; + var top = new Toplevel (); + top.Add (lv); + app.Begin (top); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" + Item 0 + Item 1 + Item 2 + Item 3 + Item 4", + _output, app.Driver); + + lv.LeftItem = 1; + lv.TopItem = 1; + app.LayoutAndDraw (); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @" + tem 1 + tem 2 + tem 3 + tem 4", + _output, app.Driver); + top.Dispose (); + app.Shutdown (); + } + + [Fact] + public void RowRender_Event () + { + IApplication? app = Application.Create (); + app.Init ("fake"); + + var rendered = false; + ObservableCollection source = ["one", "two", "three"]; + var lv = new ListView { Width = Dim.Fill (), Height = Dim.Fill () }; + lv.RowRender += (s, _) => rendered = true; + var top = new Toplevel (); + top.Add (lv); + app.Begin (top); + Assert.False (rendered); + + lv.SetSource (source); + lv.Draw (); + Assert.True (rendered); + top.Dispose (); + app.Shutdown (); + } + + [Fact] + public void Vertical_ScrollBar_Hides_And_Shows_As_Needed () + { + IApplication? app = Application.Create (); + app.Init ("fake"); + + var lv = new ListView + { + Width = 10, + Height = 3 + }; + lv.VerticalScrollBar.AutoShow = true; + lv.SetSource (["One", "Two", "Three", "Four", "Five"]); + var top = new Toplevel (); + top.Add (lv); + app.Begin (top); + + Assert.True (lv.VerticalScrollBar.Visible); + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +One ▲ +Two █ +Three ▼", + _output, app?.Driver); + + lv.Height = 5; + app?.LayoutAndDraw (); + + Assert.False (lv.VerticalScrollBar.Visible); + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +One +Two +Three +Four +Five ", + _output, app?.Driver); + top.Dispose (); + app?.Shutdown (); + } + + [Fact] + public void Mouse_Wheel_Scrolls () + { + IApplication? app = Application.Create (); + app.Init ("fake"); + + var lv = new ListView + { + Width = 10, + Height = 3, + }; + lv.SetSource (["One", "Two", "Three", "Four", "Five"]); + var top = new Toplevel (); + top.Add (lv); + app.Begin (top); + + // Initially, we are at the top. + Assert.Equal (0, lv.TopItem); + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +One +Two +Three", + _output, app?.Driver); + + // Scroll down + app.Mouse.RaiseMouseEvent (new () { ScreenPosition = new (0, 0), Flags = MouseFlags.WheeledDown }); + app.LayoutAndDraw (); + Assert.Equal (1, lv.TopItem); + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +Two +Three +Four ", + _output, app?.Driver); + + // Scroll up + app.Mouse.RaiseMouseEvent (new () { ScreenPosition = new (0, 0), Flags = MouseFlags.WheeledUp }); + app.LayoutAndDraw (); + Assert.Equal (0, lv.TopItem); + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +One +Two +Three", + _output, app?.Driver); + + top.Dispose (); + app.Shutdown (); + } + + [Fact] + public void SelectedItem_With_Source_Null_Does_Nothing () + { + var lv = new ListView (); + Assert.Null (lv.Source); + + // should not throw + lv.SelectedItem = 0; + + Assert.Null (lv.SelectedItem); + } + + [Fact] + public void Horizontal_Scroll () + { + IApplication? app = Application.Create (); + app.Init ("fake"); + + var lv = new ListView + { + Width = 10, + Height = 3, + }; + lv.SetSource (["One", "Two", "Three - long", "Four", "Five"]); + var top = new Toplevel (); + top.Add (lv); + app.Begin (top); + + Assert.Equal (0, lv.LeftItem); + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +One +Two +Three - lo", + _output, app?.Driver); + + lv.ScrollHorizontal (1); + app.LayoutAndDraw (); + Assert.Equal (1, lv.LeftItem); + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +ne +wo +hree - lon", + _output, app?.Driver); + + // Scroll right with mouse + app.Mouse.RaiseMouseEvent (new () { ScreenPosition = new (0, 0), Flags = MouseFlags.WheeledRight }); + app.LayoutAndDraw (); + Assert.Equal (2, lv.LeftItem); + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +e +o +ree - long", + _output, app?.Driver); + + // Scroll left with mouse + app.Mouse.RaiseMouseEvent (new () { ScreenPosition = new (0, 0), Flags = MouseFlags.WheeledLeft }); + app.LayoutAndDraw (); + Assert.Equal (1, lv.LeftItem); + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +ne +wo +hree - lon", + _output, app?.Driver); + + top.Dispose (); + app.Shutdown (); + } + + [Fact] + public async Task SetSourceAsync_SetsSource () + { + var lv = new ListView (); + var source = new ObservableCollection { "One", "Two", "Three" }; + + await lv.SetSourceAsync (source); + + Assert.NotNull (lv.Source); + Assert.Equal (3, lv.Source.Count); + } + + [Fact] + public void AllowsMultipleSelection_Set_To_False_Unmarks_All_But_Selected () + { + var lv = new ListView { AllowsMarking = true, AllowsMultipleSelection = true }; + var source = new ListWrapper (["One", "Two", "Three"]); + lv.Source = source; + + lv.SelectedItem = 0; + source.SetMark (0, true); + source.SetMark (1, true); + source.SetMark (2, true); + + Assert.True (source.IsMarked (0)); + Assert.True (source.IsMarked (1)); + Assert.True (source.IsMarked (2)); + + lv.AllowsMultipleSelection = false; + + Assert.True (source.IsMarked (0)); + Assert.False (source.IsMarked (1)); + Assert.False (source.IsMarked (2)); + } + + [Fact] + public void Source_CollectionChanged_Remove () + { + var source = new ObservableCollection { "One", "Two", "Three" }; + var lv = new ListView { Source = new ListWrapper (source) }; + + lv.SelectedItem = 2; + Assert.Equal (2, lv.SelectedItem); + Assert.Equal (3, lv.Source.Count); + + source.RemoveAt (0); + + Assert.Equal (2, lv.Source.Count); + Assert.Equal (1, lv.SelectedItem); + + source.RemoveAt (1); + Assert.Equal (1, lv.Source.Count); + Assert.Equal (0, lv.SelectedItem); } } diff --git a/Tests/UnitTestsParallelizable/Views/NumericUpDownTests.cs b/Tests/UnitTestsParallelizable/Views/NumericUpDownTests.cs index 4a99752449..7e72ebe5f9 100644 --- a/Tests/UnitTestsParallelizable/Views/NumericUpDownTests.cs +++ b/Tests/UnitTestsParallelizable/Views/NumericUpDownTests.cs @@ -112,7 +112,7 @@ public void WhenCreatedWithValidNumberType_ShouldThrowInvalidOperationException_ public void WhenCreated_ShouldHaveDefaultWidthAndHeight_int () { NumericUpDown numericUpDown = new (); - numericUpDown.SetRelativeLayout (Application.Screen.Size); + numericUpDown.SetRelativeLayout (new (100, 100)); Assert.Equal (3, numericUpDown.Frame.Width); Assert.Equal (1, numericUpDown.Frame.Height); @@ -122,7 +122,7 @@ public void WhenCreated_ShouldHaveDefaultWidthAndHeight_int () public void WhenCreated_ShouldHaveDefaultWidthAndHeight_float () { NumericUpDown numericUpDown = new (); - numericUpDown.SetRelativeLayout (Application.Screen.Size); + numericUpDown.SetRelativeLayout (new (100, 100)); Assert.Equal (3, numericUpDown.Frame.Width); Assert.Equal (1, numericUpDown.Frame.Height); @@ -132,7 +132,7 @@ public void WhenCreated_ShouldHaveDefaultWidthAndHeight_float () public void WhenCreated_ShouldHaveDefaultWidthAndHeight_double () { NumericUpDown numericUpDown = new (); - numericUpDown.SetRelativeLayout (Application.Screen.Size); + numericUpDown.SetRelativeLayout (new (100, 100)); Assert.Equal (3, numericUpDown.Frame.Width); Assert.Equal (1, numericUpDown.Frame.Height); @@ -142,7 +142,7 @@ public void WhenCreated_ShouldHaveDefaultWidthAndHeight_double () public void WhenCreated_ShouldHaveDefaultWidthAndHeight_long () { NumericUpDown numericUpDown = new (); - numericUpDown.SetRelativeLayout (Application.Screen.Size); + numericUpDown.SetRelativeLayout (new (100, 100)); Assert.Equal (3, numericUpDown.Frame.Width); Assert.Equal (1, numericUpDown.Frame.Height); @@ -152,7 +152,7 @@ public void WhenCreated_ShouldHaveDefaultWidthAndHeight_long () public void WhenCreated_ShouldHaveDefaultWidthAndHeight_decimal () { NumericUpDown numericUpDown = new (); - numericUpDown.SetRelativeLayout (Application.Screen.Size); + numericUpDown.SetRelativeLayout (new (100, 100)); Assert.Equal (3, numericUpDown.Frame.Width); Assert.Equal (1, numericUpDown.Frame.Height); diff --git a/docfx/docs/application.md b/docfx/docs/application.md index b999b1fc03..298cf6ff56 100644 --- a/docfx/docs/application.md +++ b/docfx/docs/application.md @@ -8,7 +8,7 @@ Terminal.Gui v2 uses an instance-based application architecture that decouples v graph TB subgraph ViewTree["View Hierarchy (SuperView/SubView)"] direction TB - Top[Application.Current
Window] + Top[app.Current
Window] Menu[MenuBar] Status[StatusBar] Content[Content View] @@ -22,7 +22,7 @@ graph TB Content --> Button2 end - subgraph Stack["Application.SessionStack"] + subgraph Stack["app.SessionStack"] direction TB S1[Window
Currently Active] S2[Previous Toplevel
Waiting] @@ -41,7 +41,7 @@ graph TB ```mermaid sequenceDiagram - participant App as Application + participant App as IApplication participant Main as Main Window participant Dialog as Dialog @@ -68,24 +68,29 @@ sequenceDiagram ### Instance-Based vs Static -**Terminal.Gui v2** has transitioned from a static singleton pattern to an instance-based architecture: +**Terminal.Gui v2** supports both static and instance-based patterns. The static `Application` class is marked obsolete but still functional for backward compatibility. The recommended pattern is to use `Application.Create()` to get an `IApplication` instance: ```csharp -// OLD (v1 / early v2 - now obsolete): +// OLD (v1 / early v2 - still works but obsolete): Application.Init(); -Application.Top.Add(myView); -Application.Run(); +var top = new Toplevel(); +top.Add(myView); +Application.Run(top); +top.Dispose(); Application.Shutdown(); -// NEW (v2 instance-based): -var app = Application.Create (); +// NEW (v2 recommended - instance-based): +var app = Application.Create(); app.Init(); var top = new Toplevel(); top.Add(myView); app.Run(top); +top.Dispose(); app.Shutdown(); ``` +**Note:** The static `Application` class delegates to `ApplicationImpl.Instance` (a singleton). `Application.Create()` creates a **new** `ApplicationImpl` instance, enabling multiple application contexts and better testability. + ### View.App Property Every view now has an `App` property that references its application context: @@ -226,19 +231,23 @@ int sessionCount = App?.SessionStack.Count ?? 0; ## Migration from Static Application -The static `Application` class now delegates to `ApplicationImpl.Instance` and is marked obsolete: +The static `Application` class delegates to `ApplicationImpl.Instance` (a singleton) and is marked obsolete. All static methods and properties are marked with `[Obsolete]` but remain functional for backward compatibility: ```csharp -public static class Application +public static partial class Application { - [Obsolete("Use ApplicationImpl.Instance.Current or view.App?.Current")] - public static Toplevel? Current => Instance?.Current; + [Obsolete("The legacy static Application object is going away.")] + public static Toplevel? Current => ApplicationImpl.Instance.Current; - [Obsolete("Use ApplicationImpl.Instance.SessionStack or view.App?.SessionStack")] - public static ConcurrentStack SessionStack => Instance?.SessionStack ?? new(); + [Obsolete("The legacy static Application object is going away.")] + public static ConcurrentStack SessionStack => ApplicationImpl.Instance.SessionStack; + + // ... other obsolete static members } ``` +**Important:** The static `Application` class uses a singleton (`ApplicationImpl.Instance`), while `Application.Create()` creates new instances. For new code, prefer the instance-based pattern using `Application.Create()`. + ### Migration Strategies **Strategy 1: Use View.App** @@ -472,16 +481,19 @@ public class Service } ``` -### DON'T: Assume Application.Instance Exists +### DON'T: Use Static Application in New Code ```csharp -❌ AVOID: -public class Service +❌ AVOID (obsolete pattern): +public void Refresh() { - public void DoWork() - { - var app = Application.Instance; // Might be null! - } + Application.Current?.SetNeedsDraw(); // Obsolete static access +} + +✅ PREFERRED: +public void Refresh() +{ + App?.Current?.SetNeedsDraw(); // Use View.App property } ``` diff --git a/docfx/docs/config.md b/docfx/docs/config.md index 4a549d5ce9..e260cad347 100644 --- a/docfx/docs/config.md +++ b/docfx/docs/config.md @@ -459,7 +459,8 @@ ThemeManager.ThemeChanged += (sender, e) => { // Theme has changed // Refresh all views to use new theme - Application.Current?.SetNeedsDraw(); + // From within a View, use: App?.Current?.SetNeedsDraw(); + // Or access via IApplication instance: app.Current?.SetNeedsDraw(); }; ``` diff --git a/docfx/docs/index.md b/docfx/docs/index.md index 830ec3c19d..437d34ffcf 100644 --- a/docfx/docs/index.md +++ b/docfx/docs/index.md @@ -13,10 +13,13 @@ Welcome to the Terminal.Gui documentation! This comprehensive guide covers every - [Getting Started](~/docs/getting-started.md) - Quick start guide to create your first Terminal.Gui application - [Migrating from v1 to v2](~/docs/migratingfromv1.md) - Complete guide for upgrading existing applications - [What's New in v2](~/docs/newinv2.md) - Overview of new features and improvements +- [Showcase](~/docs/showcase.md) - Showcase of TUI apps built with Terminal.Gui ## Deep Dives - [ANSI Response Parser](~/docs/ansiparser.md) - Terminal sequence parsing and state management +- [Application](~/docs/application.md) - Application lifecycle, initialization, and main loop +- [Arrangement](~/docs/arrangement.md) - View arrangement and positioning strategies - [Cancellable Work Pattern](~/docs/cancellable-work-pattern.md) - Core design pattern for extensible workflows - [Character Map Scenario](~/docs/CharacterMap.md) - Complex drawing, scrolling, and Unicode rendering example - [Command System](~/docs/command.md) - Command execution, key bindings, and the Selecting/Accepting concepts @@ -24,6 +27,7 @@ Welcome to the Terminal.Gui documentation! This comprehensive guide covers every - [Cross-Platform Driver Model](~/docs/drivers.md) - Platform abstraction and console driver architecture - [Cursor System](~/docs/cursor.md) - Modern cursor management and positioning (proposed design) - [Dim.Auto](~/docs/dimauto.md) - Automatic view sizing based on content +- [Drawing](~/docs/drawing.md) - Drawing primitives, rendering, and graphics operations - [Events](~/docs/events.md) - Event patterns and handling throughout the framework - [Keyboard Input](~/docs/keyboard.md) - Key handling, bindings, commands, and shortcuts - [Layout System](~/docs/layout.md) - View positioning, sizing, and arrangement @@ -33,7 +37,11 @@ Welcome to the Terminal.Gui documentation! This comprehensive guide covers every - [Mouse Input](~/docs/mouse.md) - Mouse event handling and interaction patterns - [Navigation](~/docs/navigation.md) - Focus management, keyboard navigation, and accessibility - [Popovers](~/docs/Popovers.md) - Drawing outside viewport boundaries for menus and popups +- [Scheme](~/docs/scheme.md) - Color schemes, styling, and visual theming - [Scrolling](~/docs/scrolling.md) - Built-in scrolling, virtual content areas, and scroll bars +- [TableView](~/docs/tableview.md) - Table view component, data binding, and column management +- [TreeView](~/docs/treeview.md) - Tree view component, hierarchical data, and node management +- [View](~/docs/View.md) - Base view class, view hierarchy, and core view functionality ## API Reference diff --git a/docfx/docs/migratingfromv1.md b/docfx/docs/migratingfromv1.md index 8a459e0a0a..d7fd2c0b02 100644 --- a/docfx/docs/migratingfromv1.md +++ b/docfx/docs/migratingfromv1.md @@ -93,6 +93,74 @@ In v1, @Terminal.Gui./Terminal.Gui.Application.Init) automatically created a top * Update any code that assumes `Application.Init` automatically created a toplevel view and set `Application.Current`. * Update any code that assumes `Application.Init` automatically disposed of the toplevel view when the application exited. +## Instance-Based Application Architecture + +See the [Application Deep Dive](application.md) for complete details on the new application architecture. + +Terminal.Gui v2 introduces an instance-based application architecture. While the static `Application` class still works (marked obsolete), the recommended pattern is to use `Application.Create()` to get an `IApplication` instance. + +### Key Changes + +- **Static Application is Obsolete**: The static `Application` class delegates to `ApplicationImpl.Instance` (a singleton) and is marked `[Obsolete]` but remains functional for backward compatibility. +- **Recommended Pattern**: Use `Application.Create()` to get a new `IApplication` instance for better testability and multiple application contexts. +- **View.App Property**: Every view has an `App` property that references its `IApplication` context, enabling views to access application services without static dependencies. + +### Migration Strategies + +**Option 1: Continue Using Static Application (Backward Compatible)** + +The static `Application` class still works, so existing v1 code can continue to work with minimal changes: + +```csharp +// v1 code (still works in v2, but obsolete) +Application.Init(); +var top = new Toplevel(); +top.Add(myView); +Application.Run(top); +top.Dispose(); +Application.Shutdown(); +``` + +**Option 2: Migrate to Instance-Based Pattern (Recommended)** + +For new code or when refactoring, use the instance-based pattern: + +```csharp +// v2 recommended pattern +var app = Application.Create(); +app.Init(); +var top = new Toplevel(); +top.Add(myView); +app.Run(top); +top.Dispose(); +app.Shutdown(); +``` + +**Option 3: Use View.App Property** + +When accessing application services from within views, use the `App` property instead of static `Application`: + +```csharp +// OLD (v1 / obsolete static): +public void Refresh() +{ + Application.Current?.SetNeedsDraw(); +} + +// NEW (v2 - use View.App): +public void Refresh() +{ + App?.Current?.SetNeedsDraw(); +} +``` + +### Benefits of Instance-Based Architecture + +- **Testability**: Views can be tested without `Application.Init()` by setting `view.App = mockApp` +- **Multiple Contexts**: Multiple `IApplication` instances can coexist +- **Clear Ownership**: Views explicitly know their application context +- **Reduced Global State**: Less reliance on static singletons + ## @Terminal.Gui.Pos and @Terminal.Gui.Dim types now adhere to standard C# idioms * In v1, the @Terminal.Gui.Pos and @Terminal.Gui.Dim types (e.g. @Terminal.Gui.Pos.PosView) were nested classes and marked @Terminal.Gui.internal. In v2, they are no longer nested, and have appropriate public APIs. diff --git a/docfx/docs/multitasking.md b/docfx/docs/multitasking.md index a4e98b8c55..1659139250 100644 --- a/docfx/docs/multitasking.md +++ b/docfx/docs/multitasking.md @@ -9,7 +9,7 @@ Terminal.Gui applications run on a single main thread with an event loop that pr Terminal.Gui follows the standard UI toolkit pattern where **all UI operations must happen on the main thread**. Attempting to modify views or their properties from background threads will result in undefined behavior and potential crashes. ### The Golden Rule -> Always use `Application.Invoke()` to update the UI from background threads. +> Always use `Application.Invoke()` (static, obsolete) or `app.Invoke()` (instance-based, recommended) to update the UI from background threads. From within a View, use `App?.Invoke()`. ## Background Operations @@ -47,6 +47,7 @@ private async void LoadDataButton_Clicked() When working with traditional threading APIs or when async/await isn't suitable: +**From within a View (recommended):** ```csharp private void StartBackgroundWork() { @@ -58,14 +59,14 @@ private void StartBackgroundWork() Thread.Sleep(50); // Simulate work // Marshal back to main thread for UI updates - Application.Invoke(() => + App?.Invoke(() => { progressBar.Fraction = i / 100f; statusLabel.Text = $"Progress: {i}%"; }); } - Application.Invoke(() => + App?.Invoke(() => { statusLabel.Text = "Complete!"; }); @@ -73,6 +74,41 @@ private void StartBackgroundWork() } ``` +**Using IApplication instance (recommended):** +```csharp +var app = Application.Create(); +app.Init(); + +private void StartBackgroundWork(IApplication app) +{ + Task.Run(() => + { + // This code runs on a background thread + for (int i = 0; i <= 100; i++) + { + Thread.Sleep(50); // Simulate work + + // Marshal back to main thread for UI updates + app.Invoke(() => + { + progressBar.Fraction = i / 100f; + statusLabel.Text = $"Progress: {i}%"; + }); + } + + app.Invoke(() => + { + statusLabel.Text = "Complete!"; + }); + }); +} +``` + +**Using static Application (obsolete but still works):** +```csharp +Application.Invoke(() => { /* ... */ }); +``` + ## Timers Use timers for periodic updates like clocks, status refreshes, or animations: @@ -89,10 +125,11 @@ public class ClockView : View Add(timeLabel); // Update every second - timerToken = Application.AddTimeout( + // Use App?.AddTimeout() when available, or Application.AddTimeout() (obsolete) + timerToken = App?.AddTimeout( TimeSpan.FromSeconds(1), UpdateTime - ); + ) ?? Application.AddTimeout(TimeSpan.FromSeconds(1), UpdateTime); } private bool UpdateTime() @@ -105,7 +142,7 @@ public class ClockView : View { if (disposing && timerToken != null) { - Application.RemoveTimeout(timerToken); + App?.RemoveTimeout(timerToken) ?? Application.RemoveTimeout(timerToken); } base.Dispose(disposing); } @@ -220,6 +257,13 @@ Task.Run(() => ### ❌ Don't: Forget to clean up timers ```csharp // Memory leak - timer keeps running after view is disposed +// From within a View: +App?.AddTimeout(TimeSpan.FromSeconds(1), UpdateStatus); + +// Or with IApplication instance: +app.AddTimeout(TimeSpan.FromSeconds(1), UpdateStatus); + +// Or static (obsolete but works): Application.AddTimeout(TimeSpan.FromSeconds(1), UpdateStatus); ``` diff --git a/docfx/docs/navigation.md b/docfx/docs/navigation.md index 7fde067950..2c5c2391af 100644 --- a/docfx/docs/navigation.md +++ b/docfx/docs/navigation.md @@ -176,25 +176,30 @@ The @Terminal.Gui.App.ApplicationNavigation.AdvanceFocus method causes the focus The implementation is simple: ```cs -return Application.Current?.AdvanceFocus (direction, behavior); +return app.Current?.AdvanceFocus (direction, behavior); ``` -This method is called from the `Command` handlers bound to the application-scoped keybindings created during `Application.Init`. It is `public` as a convenience. +This method is called from the `Command` handlers bound to the application-scoped keybindings created during `app.Init()`. It is `public` as a convenience. + +**Note:** When accessing from within a View, use `App?.Current` instead of `Application.Current` (which is obsolete). This method replaces about a dozen functions in v1 (scattered across `Application` and `Toplevel`). ### Application Navigation Examples ```csharp +var app = Application.Create(); +app.Init(); + // Listen for global focus changes -Application.Navigation.FocusedChanged += (sender, e) => +app.Navigation.FocusedChanged += (sender, e) => { - var focused = Application.Navigation.GetFocused(); + var focused = app.Navigation.GetFocused(); StatusBar.Text = $"Focused: {focused?.GetType().Name ?? "None"}"; }; // Prevent certain views from getting focus -Application.Navigation.FocusedChanging += (sender, e) => +app.Navigation.FocusedChanging += (sender, e) => { if (e.NewView is SomeRestrictedView) { diff --git a/docfx/docs/newinv2.md b/docfx/docs/newinv2.md index e416dd7547..43dec8e87d 100644 --- a/docfx/docs/newinv2.md +++ b/docfx/docs/newinv2.md @@ -15,6 +15,47 @@ Terminal.Gui v2 represents a fundamental rethinking of the library's architectur This architectural shift has resulted in the removal of thousands of lines of redundant or overly complex code from v1, replaced with cleaner, more focused implementations. +## Instance-Based Application Architecture + +See the [Application Deep Dive](application.md) for complete details on the new application architecture. + +Terminal.Gui v2 introduces an instance-based application architecture that decouples views from global application state, dramatically improving testability and enabling multiple application contexts. + +### Key Changes + +- **Instance-Based Pattern**: The recommended pattern is to use `Application.Create()` to get an `IApplication` instance, rather than using the static `Application` class (which is marked obsolete but still functional for backward compatibility). +- **View.App Property**: Every view now has an `App` property that references its `IApplication` context, enabling views to access application services without static dependencies. +- **Session Management**: Applications manage sessions through `Begin()` and `End()` methods, with a `SessionStack` tracking nested sessions and `Current` representing the active session. +- **Improved Testability**: Views can be tested in isolation by setting their `App` property to a mock `IApplication`, eliminating the need for `Application.Init()` in unit tests. + +### Example Usage + +```csharp +// Recommended v2 pattern (instance-based) +var app = Application.Create(); +app.Init(); +var top = new Toplevel { Title = "My App" }; +top.Add(myView); +app.Run(top); +top.Dispose(); +app.Shutdown(); + +// Static pattern (obsolete but still works) +Application.Init(); +var top = new Toplevel { Title = "My App" }; +top.Add(myView); +Application.Run(top); +top.Dispose(); +Application.Shutdown(); +``` + +### Benefits + +- **Testability**: Views can be tested without initializing the entire application +- **Multiple Contexts**: Multiple `IApplication` instances can coexist (useful for testing or complex scenarios) +- **Clear Ownership**: Views explicitly know their application context via the `App` property +- **Reduced Global State**: Less reliance on static singletons improves code maintainability + ## Modern Look & Feel - Technical Details ### TrueColor Support diff --git a/docfx/docs/toc.yml b/docfx/docs/toc.yml index 8acb4573f2..c251920600 100644 --- a/docfx/docs/toc.yml +++ b/docfx/docs/toc.yml @@ -2,10 +2,16 @@ href: index.md - name: Getting Started href: getting-started.md +- name: Showcase + href: showcase.md - name: What's new in v2 href: newinv2.md - name: v1 To v2 Migration href: migratingfromv1.md +- name: Lexicon & Taxonomy + href: lexicon.md +- name: Application Deep Dive + href: application.md - name: Arrangement href: arrangement.md - name: Cancellable Work Pattern @@ -24,10 +30,6 @@ href: drivers.md - name: Events Deep Dive href: events.md -- name: Lexicon & Taxonomy - href: lexicon.md -- name: Terminology Proposal - href: terminology-index.md - name: Keyboard href: keyboard.md - name: Layout Engine @@ -40,14 +42,16 @@ href: navigation.md - name: Popovers href: Popovers.md -- name: View Deep Dive - href: View.md -- name: View List - href: views.md +- name: Scheme Deep Dive + href: scheme.md - name: Scrolling href: scrolling.md - name: TableView Deep Dive href: tableview.md - name: TreeView Deep Dive href: treeview.md +- name: View Deep Dive + href: View.md +- name: View List + href: views.md