From a6258ed398cb080df26df1a539eb1fb2335d35c0 Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 19 Nov 2025 20:39:34 -0500 Subject: [PATCH] Updates `IListDataSource.Render` to rename the `start` parameter to `viewportXOffset` (#4392) * Add comprehensive unit tests for WindowsKeyConverter - Implement 118 parallelizable unit tests for WindowsKeyConverter - Cover ToKey and ToKeyInfo methods with full bidirectional testing - Test basic characters, modifiers, special keys, function keys - Test VK_PACKET Unicode/IME input - Test OEM keys, NumPad keys, and lock states - Include round-trip conversion tests - All tests passing successfully Fixes #4389 * Rename `start` parameter to `viewportXOffset` for clarity The `start` parameter in several methods and interfaces has been renamed to `viewportXOffset` to better reflect its purpose as the horizontal offset of the viewport during string rendering. - Updated method signatures in `ListViewWithSelection` to use `viewportXOffset` instead of `start`, including default values. - Modified the `RenderUstr` method in `ListViewWithSelection` to use `viewportXOffset` for calculating the starting index. - Renamed the `start` parameter to `viewportXOffset` in the `IListDataSource` interface and updated its documentation. - Replaced all occurrences of `start` with `viewportXOffset` in the `ListWrapper` class, including method calls and logic. - Updated the `RenderUstr` method in `ListWrapper` to use `viewportXOffset` for substring calculations. - Adjusted the test method in `ListViewTests.cs` to reflect the parameter name change. These changes improve code readability and make the parameter's role in rendering logic more explicit. * Remove WindowsKeyConverterTests class that was added by mistake * Modernized ListView and IListDataSource - Tons of new unit tests Refactored `ListView` and `IListDataSource` to improve readability, maintainability, and functionality. Introduced `ListWrapper` as a default implementation of `IListDataSource` for easier integration with standard collections. Enhanced `ListView` with better handling of marking, selection, and scrolling. Replaced `viewportXOffset` with `viewportX` for horizontal scrolling. Added `EnsureSelectedItemVisible` to maintain visibility of the selected item. Updated `IListDataSource` with detailed XML documentation and added `SuspendCollectionChangedEvent` for bulk updates. Improved null safety with nullable reference types. Added comprehensive unit tests for `ListWrapper` and `IListDataSource` to ensure robustness. Modernized the codebase with C# features like expression-bodied members and pattern matching. Fixed bugs related to `SelectedItem` validation and rendering artifacts. * Improve index validation in ComboBox and ListView Enhance robustness by adding stricter checks for valid indices in ComboBox and ListView. Updated conditions in the `_listview.SelectedItemChanged` event handler to ensure `e.Item` is non-negative before accessing `_searchSet`. Modified the `SetValue` method to use `e.Item` instead of `_listview.SelectedItem`. In ListView, updated the `OnSelectedChanged` method to validate that `SelectedItem` is non-negative (`>= 0`) before accessing the `Source` list. These changes prevent potential out-of-range errors and improve code safety. * Refactor and enhance test coverage across modules Refactored and added new tests to improve coverage, readability, and consistency across multiple test files. Key changes include: - **ShortcutTests.cs**: Added tests for `BindKeyToApplication` and removed redundant tests. - **SourcesManagerTests.cs**: Renamed `Update_*` tests to `Load_*` for clarity. - **ArrangementTests.cs**: Reintroduced `MouseGrabHandler` tests, added `ViewArrangement` flag tests, and improved structure. - **NeedsDrawTests.cs**: Replaced `Application.Screen.Size` with fixed dimensions for better isolation. - **DimAutoTests.cs**: Updated layout tests to use fixed dimensions. - **FrameTests.cs**: Standardized object initialization and validated frame behavior. - **SubViewTests.cs**: Improved formatting and modernized event handling. - **NumericUpDownTests.cs**: Decoupled layout tests from screen size. General improvements: - Enhanced formatting and removed redundant tests. - Added comments for clarity. - Introduced `ITestOutputHelper` for better debugging in `ArrangementTests`. * Refactor to use nullable types for better null safety Enabled nullable reference types across the codebase to improve null safety and prevent potential null reference issues. Refactored `SelectedItem` and related properties from `int` to `int?` to represent no selection with `null` instead of `-1`. Updated logic, event arguments, and method signatures to handle nullable values consistently. Simplified object initialization using modern C# syntax and improved code readability with interpolated strings. Added null checks and early returns to prevent runtime errors. Enhanced error handling by throwing `ArgumentOutOfRangeException` for invalid values. Updated tests to reflect the changes, replacing assertions for `-1` with `null` and ensuring proper handling of nullable values. Cleaned up redundant code and improved formatting for better maintainability. * on` functionality has been deprecated, refactored, or removed from the `Shortcut` class. * Refactor: Transition to instance-based architecture Updated `Run-LocalCoverage.ps1` to increase `--blame-hang-timeout` from 10s to 60s. Improved null safety in `GuiTestContext` by adding null-conditional operators. Commented out problematic code in `SetupFakeApplicationAttribute.cs` to prevent test hangs. Excluded `ViewBase` files from `UnitTests.Parallelizable.csproj` and removed redundant folder declarations. Simplified event handling in `IListDataSourceTests.cs` and updated `ListViewTests.cs` to use nullable reference types. Enhanced documentation to emphasize the transition to an instance-based application architecture. Updated examples in `application.md`, `multitasking.md`, and `navigation.md` to reflect the use of `Application.Create()` and `View.App`. Clarified the obsolescence of the static `Application` class. Revised table of contents in `toc.yml` to include new sections like "Application Deep Dive" and "Scheme Deep Dive." Added `dotnet-tools.json` for tool configuration. These changes improve maintainability, testability, and alignment with modern C# practices. * Refactor ListViewTests to use Terminal.Gui framework The `ListViewTests` class has been refactored to replace the `AutoInitShutdown` attribute with explicit application lifecycle management using `IApplication` and `app.Init()` from the `Terminal.Gui` framework. Key changes include: - Rewriting tests to use `Terminal.Gui`'s application lifecycle. - Adding a private `_output` field for logging test output via `ITestOutputHelper`. - Updating `DriverAssert.AssertDriverContentsWithFrameAre` to include `app.Driver` for UI verification. - Rewriting tests like `Clicking_On_Border_Is_Ignored`, `EnsureSelectedItemVisible_SelectedItem`, and others to align with the new framework. - Adding explicit calls to `app.Shutdown()` for proper cleanup. - Enabling nullable reference types with `#nullable enable`. - Updating `using` directives and `namespace` to reflect the new structure. These changes improve test maintainability, compatibility, and diagnostics. * Update Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Examples/UICatalog/UICatalogTop.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Terminal.Gui/Views/ListWrapper.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Terminal.Gui/Views/ListWrapper.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Updated the `SetMark` method to return `Source.IsMarked(SelectedItem.Value)` for consistency and removed an outdated comment questioning its correctness. Enhanced the exception message in the `SelectedItem` property setter to provide clearer guidance when the value is out of range. * Add comprehensive ListView behavior test coverage Added multiple test methods to validate `ListView` behavior: - `Vertical_ScrollBar_Hides_And_Shows_As_Needed`: Ensures the vertical scrollbar auto-hides/shows based on content height. - `Mouse_Wheel_Scrolls`: Verifies vertical scrolling with the mouse wheel updates `TopItem`. - `SelectedItem_With_Source_Null_Does_Nothing`: Confirms no exceptions occur when setting `SelectedItem` with a `null` source. - `Horizontal_Scroll`: Tests horizontal scrolling, including programmatic and mouse wheel interactions, ensuring `LeftItem` updates correctly. - `SetSourceAsync_SetsSource`: Validates the asynchronous `SetSourceAsync` method updates the source and item count. - `AllowsMultipleSelection_Set_To_False_Unmarks_All_But_Selected`: Ensures disabling multiple selection unmarks all but the selected item. - `Source_CollectionChanged_Remove`: Confirms `SelectedItem` and source count update correctly when items are removed from the source collection. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .config/dotnet-tools.json | 5 + .../UICatalog/Scenarios/AllViewsTester.cs | 2 +- .../UICatalog/Scenarios/ComboBoxIteration.cs | 4 +- .../UICatalog/Scenarios/DynamicMenuBar.cs | 24 +- .../UICatalog/Scenarios/DynamicStatusBar.cs | 29 +- .../Scenarios/ListViewWithSelection.cs | 8 +- .../UICatalog/Scenarios/ListsAndCombos.cs | 2 +- Examples/UICatalog/Scenarios/SpinnerStyles.cs | 2 +- Examples/UICatalog/UICatalogTop.cs | 14 +- Scripts/Run-LocalCoverage.ps1 | 2 +- .../CollectionNavigatorBase.cs | 74 +- .../ICollectionNavigator.cs | 8 +- Terminal.Gui/Views/ComboBox.cs | 27 +- Terminal.Gui/Views/IListDataSource.cs | 107 +- Terminal.Gui/Views/ListView.cs | 1451 +++++++-------- Terminal.Gui/Views/ListViewEventArgs.cs | 9 +- Terminal.Gui/Views/ListWrapper.cs | 256 +++ Terminal.Gui/Views/TableView/TableView.cs | 6 +- Terminal.Gui/Views/Toplevel.cs | 2 +- Terminal.sln.DotSettings | 1 + .../UICatalog/ScenarioTests.cs | 4 +- .../GuiTestContext.ContextMenu.cs | 2 +- .../GuiTestContext.ViewBase.cs | 4 +- .../SetupFakeApplicationAttribute.cs | 5 +- Tests/UnitTests/Views/ListViewTests.cs | 1225 ------------- .../Configuration/SourcesManagerTests.cs | 22 +- .../Text/CollectionNavigatorTests.cs | 30 +- .../UnitTests.Parallelizable.csproj | 8 +- .../View/Draw/NeedsDrawTests.cs | 10 +- .../View/Layout/Dim.AutoTests.PosTypes.cs | 2 +- .../View/Layout/Dim.AutoTests.cs | 14 +- .../View/Layout/FrameTests.cs | 10 +- .../View/SubviewTests.cs | 12 +- .../Views/IListDataSourceTests.cs | 513 ++++++ .../Views/ListViewTests.cs | 1556 ++++++++++++++++- .../Views/NumericUpDownTests.cs | 10 +- docfx/docs/application.md | 56 +- docfx/docs/config.md | 3 +- docfx/docs/index.md | 8 + docfx/docs/migratingfromv1.md | 68 + docfx/docs/multitasking.md | 56 +- docfx/docs/navigation.md | 15 +- docfx/docs/newinv2.md | 41 + docfx/docs/toc.yml | 20 +- 44 files changed, 3372 insertions(+), 2355 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 Terminal.Gui/Views/ListWrapper.cs delete mode 100644 Tests/UnitTests/Views/ListViewTests.cs create mode 100644 Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 000000000..b0e38abda --- /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 2f6e9e016..5ac97bea9 100644 --- a/Examples/UICatalog/Scenarios/AllViewsTester.cs +++ b/Examples/UICatalog/Scenarios/AllViewsTester.cs @@ -65,7 +65,7 @@ public class AllViewsTester : Scenario // 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 9440f37f3..003926396 100644 --- a/Examples/UICatalog/Scenarios/ComboBoxIteration.cs +++ b/Examples/UICatalog/Scenarios/ComboBoxIteration.cs @@ -42,8 +42,8 @@ public class ComboBoxIteration : Scenario 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 2687b4f6c..28a13a916 100644 --- a/Examples/UICatalog/Scenarios/DynamicMenuBar.cs +++ b/Examples/UICatalog/Scenarios/DynamicMenuBar.cs @@ -712,7 +712,7 @@ public class DynamicMenuBar : Scenario 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 class DynamicMenuBar : Scenario 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 class DynamicMenuBar : Scenario : MenuItemCheckStyle.Radio, ShortcutKey = frmMenuDetails.TextShortcutKey.Text }; - UpdateMenuItem (_currentEditMenuBarItem, menuItem, _lstMenus.SelectedItem); + UpdateMenuItem (_currentEditMenuBarItem, menuItem, _lstMenus.SelectedItem.Value); } }; @@ -885,8 +885,8 @@ public class DynamicMenuBar : Scenario 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 class DynamicMenuBar : Scenario 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 class DynamicMenuBar : Scenario _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 class DynamicMenuBar : Scenario _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 @@ public class DynamicMenuBar : Scenario 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 de110d0c6..73dd3b802 100644 --- a/Examples/UICatalog/Scenarios/DynamicStatusBar.cs +++ b/Examples/UICatalog/Scenarios/DynamicStatusBar.cs @@ -312,7 +312,12 @@ public class DynamicStatusBar : Scenario 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 class DynamicStatusBar : Scenario 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 class DynamicStatusBar : Scenario } 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 class DynamicStatusBar : Scenario 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 class DynamicStatusBar : Scenario _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 @@ public class DynamicStatusBar : Scenario 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 874d061a5..a20eb67af 100644 --- a/Examples/UICatalog/Scenarios/ListViewWithSelection.cs +++ b/Examples/UICatalog/Scenarios/ListViewWithSelection.cs @@ -237,7 +237,7 @@ public class ListViewWithSelection : Scenario int col, int line, int width, - int start = 0 + int viewportX = 0 ) { container.Move (col, line); @@ -247,7 +247,7 @@ public class ListViewWithSelection : Scenario 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 @@ public class ListViewWithSelection : Scenario } // 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 dba1adc73..775c78d24 100644 --- a/Examples/UICatalog/Scenarios/ListsAndCombos.cs +++ b/Examples/UICatalog/Scenarios/ListsAndCombos.cs @@ -50,7 +50,7 @@ public class ListsAndCombos : Scenario 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 e9e923ae7..87e2b1b3b 100644 --- a/Examples/UICatalog/Scenarios/SpinnerStyles.cs +++ b/Examples/UICatalog/Scenarios/SpinnerStyles.cs @@ -153,7 +153,7 @@ public class SpinnerViewStyles : Scenario 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 88a5e4144..63c3eb528 100644 --- a/Examples/UICatalog/UICatalogTop.cs +++ b/Examples/UICatalog/UICatalogTop.cs @@ -43,7 +43,11 @@ public class UICatalogTop : Toplevel 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 @@ public class UICatalogTop : Toplevel #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 @@ public class UICatalogTop : Toplevel 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 312b229a9..32b88053d 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 39234b10b..7fd71f491 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 @@ internal abstract class CollectionNavigatorBase : ICollectionNavigator 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 @@ internal abstract class CollectionNavigatorBase : ICollectionNavigator // 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 @@ internal abstract class CollectionNavigatorBase : ICollectionNavigator { // 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 @@ internal abstract class CollectionNavigatorBase : ICollectionNavigator // 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 @@ internal abstract class CollectionNavigatorBase : ICollectionNavigator 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 @@ internal abstract class CollectionNavigatorBase : ICollectionNavigator /// 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 @@ internal abstract class CollectionNavigatorBase : ICollectionNavigator /// (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 abstract class CollectionNavigatorBase : ICollectionNavigator 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 abstract class CollectionNavigatorBase : ICollectionNavigator } // 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 5ea621646..69256db43 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 86c4f4f79..83e72ac79 100644 --- a/Terminal.Gui/Views/ComboBox.cs +++ b/Terminal.Gui/Views/ComboBox.cs @@ -47,9 +47,9 @@ public class ComboBox : View, IDesignable }; _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 class ComboBox : View, IDesignable /// protected override bool OnSettingScheme (ValueChangingEventArgs args) { - _listview.SetScheme(args.NewValue); + _listview.SetScheme (args.NewValue); return base.OnSettingScheme (args); } @@ -461,7 +461,10 @@ public class ComboBox : View, IDesignable 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 @@ public class ComboBox : View, IDesignable _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 @@ public class ComboBox : View, IDesignable 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 @@ public class ComboBox : View, IDesignable 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 class ComboBox : View, IDesignable { bool res = base.OnSelectedChanged (); - _highlighted = SelectedItem; + if (SelectedItem is null) + { + return res; + } + _highlighted = SelectedItem.Value; return res; } @@ -997,7 +1004,7 @@ public class ComboBox : View, IDesignable _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 76ab7e956..d5d1e5bde 100644 --- a/Terminal.Gui/Views/IListDataSource.cs +++ b/Terminal.Gui/Views/IListDataSource.cs @@ -4,43 +4,68 @@ using System.Collections.Specialized; 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. /// - /// The default color will be set before this method is invoked, and will be based on whether the item is selected - /// or not. + /// 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. + /// + /// + /// 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 @@ public interface IListDataSource : IDisposable 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 729e8066b..e958844bf 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 class ListView : View, IDesignable // 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 class ListView : View, IDesignable 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; - } + AddCommand ( + Command.Accept, + ctx => + { + if (RaiseAccepting (ctx) == true) + { + return true; + } - if (OnOpenSelectedItem ()) - { - 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; - } + AddCommand ( + Command.Select, + ctx => + { + if (!_allowsMarking) + { + return false; + } - if (MarkUnmarkSelectedItem ()) - { - return true; - } - } - - 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; + AddCommand ( + Command.HotKey, + ctx => + { + if (SelectedItem is { }) + { + return !SetFocus (); + } - } - } + SelectedItem = 0; - return !SetFocus (); - }); + if (RaiseSelecting (ctx) == true) + { + return true; + } - AddCommand (Command.SelectAll, (ctx) => - { - if (ctx is not CommandContext keyCommandContext) - { - return false; - } + return !SetFocus (); + }); - return keyCommandContext.Binding.Data is { } && MarkAll ((bool)keyCommandContext.Binding.Data); - }); + 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 class ListView : View, IDesignable 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 class ListView : View, IDesignable 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 class ListView : View, IDesignable } } + /// + /// 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 class ListView : View, IDesignable get => Viewport.X; set { - if (_source is null) + if (Source is null) { return; } @@ -259,99 +267,6 @@ public class ListView : View, IDesignable } } - /// 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,16 +282,402 @@ public class ListView : View, IDesignable 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; } + /// Marks the if it is not already marked. + /// if the was marked. + public bool MarkUnmarkSelectedItem () + { + if (Source is null || SelectedItem is null || !UnmarkAllButSelected ()) + { + return false; + } + + Source.SetMark (SelectedItem.Value, !Source.IsMarked (SelectedItem.Value)); + SetNeedsDraw (); + + return Source.IsMarked (SelectedItem.Value); + } + + /// Gets the widest item in the list. + public int MaxLength => Source?.Length ?? 0; + + /// Changes the to the next item in the list, scrolling the list if needed. + /// + public virtual bool MoveDown () + { + if (Source is null || Source.Count == 0) + { + return false; //Nothing for us to move to + } + + if (SelectedItem is null || SelectedItem >= Source.Count) + { + // If SelectedItem is null or for some reason we are currently outside the + // valid values range, we should select the first or bottommost valid value. + // This can occur if the backing data source changes. + SelectedItem = SelectedItem is null ? 0 : Source.Count - 1; + } + else if (SelectedItem + 1 < Source.Count) + { + //can move by down by one. + SelectedItem++; + + if (SelectedItem >= Viewport.Y + Viewport.Height) + { + Viewport = Viewport with { Y = Viewport.Y + 1 }; + } + else if (SelectedItem < Viewport.Y) + { + Viewport = Viewport with { Y = SelectedItem.Value }; + } + } + else if (SelectedItem >= Viewport.Y + Viewport.Height) + { + Viewport = Viewport with { Y = Source.Count - Viewport.Height }; + } + + return true; + } + + /// Changes the to last item in the list, scrolling the list if needed. + /// + public virtual bool MoveEnd () + { + if (Source is { Count: > 0 } && SelectedItem != Source.Count - 1) + { + SelectedItem = Source.Count - 1; + + if (Viewport.Y + SelectedItem > Viewport.Height - 1) + { + Viewport = Viewport with + { + Y = SelectedItem < Viewport.Height - 1 + ? Math.Max (Viewport.Height - SelectedItem.Value + 1, 0) + : Math.Max (SelectedItem.Value - Viewport.Height + 1, 0) + }; + } + } + + return true; + } + + /// Changes the to the first item in the list, scrolling the list if needed. + /// + public virtual bool MoveHome () + { + if (SelectedItem != 0) + { + SelectedItem = 0; + Viewport = Viewport with { Y = SelectedItem.Value }; + } + + return true; + } + + /// + /// Changes the to the item just below the bottom of the visible list, scrolling if + /// needed. + /// + /// + public virtual bool MovePageDown () + { + if (Source is null || Source.Count == 0) + { + return false; + } + + int n = (SelectedItem ?? 0) + Viewport.Height; + + if (n >= Source.Count) + { + n = Source.Count - 1; + } + + if (n != SelectedItem) + { + SelectedItem = n; + + if (Source.Count >= Viewport.Height) + { + Viewport = Viewport with { Y = SelectedItem.Value }; + } + else + { + Viewport = Viewport with { Y = 0 }; + } + } + + return true; + } + + /// Changes the to the item at the top of the visible list. + /// + public virtual bool MovePageUp () + { + if (Source is null || Source.Count == 0) + { + return false; + } + + int n = (SelectedItem ?? 0) - Viewport.Height; + + if (n < 0) + { + n = 0; + } + + if (n != SelectedItem && n < Source?.Count) + { + SelectedItem = n; + Viewport = Viewport with { Y = SelectedItem.Value }; + } + + return true; + } + + /// Changes the to the previous item in the list, scrolling the list if needed. + /// + public virtual bool MoveUp () + { + if (Source is null || Source.Count == 0) + { + return false; //Nothing for us to move to + } + + if (SelectedItem is null || SelectedItem >= Source.Count) + { + // If SelectedItem is null or for some reason we are currently outside the + // valid values range, we should select the bottommost valid value. + // This can occur if the backing data source changes. + SelectedItem = Source.Count - 1; + } + else if (SelectedItem > 0) + { + SelectedItem--; + + if (SelectedItem > Source.Count) + { + SelectedItem = Source.Count - 1; + } + + if (SelectedItem < Viewport.Y) + { + Viewport = Viewport with { Y = SelectedItem.Value }; + } + else if (SelectedItem > Viewport.Y + Viewport.Height) + { + Viewport = Viewport with { Y = SelectedItem.Value - Viewport.Height + 1 }; + } + } + else if (SelectedItem < Viewport.Y) + { + Viewport = Viewport with { Y = SelectedItem.Value }; + } + + return true; + } + + /// Invokes the event if it is defined. + /// if the event was fired. + public bool OnOpenSelectedItem () + { + if (Source is null || SelectedItem is null || Source.Count <= SelectedItem || SelectedItem < 0 || OpenSelectedItem is null) + { + return false; + } + + object? value = Source.ToList () [SelectedItem.Value]; + + OpenSelectedItem?.Invoke (this, new (SelectedItem.Value, value!)); + + // BUGBUG: this should not blindly return true. + return true; + } + + /// Virtual method that will invoke the . + /// + public virtual void OnRowRender (ListViewRowEventArgs rowEventArgs) { RowRender?.Invoke (this, rowEventArgs); } + + + /// This event is raised when the user Double-Clicks on an item or presses ENTER to open the selected item. + public event EventHandler? OpenSelectedItem; + + /// + /// Allow resume the event from being invoked, + /// + public void ResumeSuspendCollectionChangedEvent () + { + if (Source is { }) + { + Source.SuspendCollectionChangedEvent = false; + } + } + + /// This event is invoked when this is being drawn before rendering. + public event EventHandler? RowRender; + + private int? _selectedItem = null; + private int? _lastSelectedItem = null; + + /// Gets or sets the index of the currently selected item. + /// The selected item or null if no item is selected. + public int? SelectedItem + { + get => _selectedItem; + set + { + if (Source is null) + { + return; + } + + if (value.HasValue && (value < 0 || value >= Source.Count)) + { + throw new ArgumentException (@"SelectedItem must be greater than 0 or less than the number of items."); + } + + _selectedItem = value; + OnSelectedChanged (); + SetNeedsDraw (); + } + } + + // TODO: Use standard event model + /// Invokes the event if it is defined. + /// + public virtual bool OnSelectedChanged () + { + if (SelectedItem != _lastSelectedItem) + { + object? value = SelectedItem.HasValue && Source?.Count > 0 ? Source.ToList () [SelectedItem.Value] : null; + SelectedItemChanged?.Invoke (this, new (SelectedItem, value)); + _lastSelectedItem = SelectedItem; + EnsureSelectedItemVisible (); + + return true; + } + + return false; + } + + /// This event is raised when the selected item in the has changed. + public event EventHandler? SelectedItemChanged; + + /// Sets the source of the to an . + /// An object implementing the IList interface. + /// + /// Use the property to set a new source and use custom + /// rendering. + /// + public void SetSource (ObservableCollection? source) + { + if (source is null && Source is not ListWrapper) + { + Source = null; + } + else + { + Source = new ListWrapper (source); + } + } + + /// Sets the source to an value asynchronously. + /// An item implementing the IList interface. + /// + /// Use the property to set a new source and use custom + /// rendering. + /// + public Task SetSourceAsync (ObservableCollection? source) + { + return Task.Factory.StartNew ( + () => + { + if (source is null && Source is not ListWrapper) + { + Source = null; + } + else + { + Source = new ListWrapper (source); + } + + return source; + }, + CancellationToken.None, + TaskCreationOptions.DenyChildAttach, + TaskScheduler.Default + ); + } + + /// Gets or sets the backing this , enabling custom rendering. + /// The source. + /// Use to set a new source. + public IListDataSource? Source + { + get => _source; + set + { + if (_source == value) + { + return; + } + + _source?.Dispose (); + _source = value; + + if (_source is { }) + { + _source.CollectionChanged += Source_CollectionChanged; + SetContentSize (new Size (_source?.Length ?? Viewport.Width, _source?.Count ?? Viewport.Width)); + KeystrokeNavigator.Collection = _source?.ToList (); + } + + SelectedItem = null; + _lastSelectedItem = null; + SetNeedsDraw (); + } + } + + /// + /// Allow suspending the event from being invoked, + /// + public void SuspendCollectionChangedEvent () + { + if (Source is { }) + { + Source.SuspendCollectionChangedEvent = true; + } + } + + /// Gets or sets the index of the item that will appear at the top of the . + /// + /// This a helper property for accessing listView.Viewport.Y. + /// + /// The top item. + public int TopItem + { + get => Viewport.Y; + set + { + if (Source is null) + { + return; + } + + Viewport = Viewport with { Y = value }; + } + } + /// /// If and are both , /// unmarks all marked items other than . @@ -391,9 +692,9 @@ public class ListView : View, IDesignable if (!AllowsMultipleSelection) { - for (var i = 0; i < Source.Count; i++) + for (var i = 0; i < Source?.Count; i++) { - if (Source.IsMarked (i) && i != _selected) + if (Source.IsMarked (i) && i != SelectedItem) { Source.SetMark (i, false); @@ -405,36 +706,121 @@ public class ListView : View, IDesignable return true; } - /// Ensures the selected item is always visible on the screen. - public void EnsureSelectedItemVisible () + /// + protected override void Dispose (bool disposing) { - if (_selected == -1) + Source?.Dispose (); + + base.Dispose (disposing); + } + + /// + /// Call the event to raises the . + /// + /// + protected virtual void OnCollectionChanged (NotifyCollectionChangedEventArgs e) { CollectionChanged?.Invoke (this, e); } + + /// + protected override bool OnDrawingContent () + { + if (Source is null) { - return; + return base.OnDrawingContent (); } - if (_selected < Viewport.Y) + + 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++) { - Viewport = Viewport with { Y = _selected }; + bool isSelected = item == SelectedItem; + + Attribute newAttribute = focused ? isSelected ? GetAttributeForRole (VisualRole.Focus) : GetAttributeForRole (VisualRole.Normal) : + isSelected ? GetAttributeForRole (VisualRole.Active) : GetAttributeForRole (VisualRole.Normal); + + if (newAttribute != current) + { + SetAttribute (newAttribute); + current = newAttribute; + } + + Move (0, row); + + if (Source is null || item >= Source.Count) + { + for (var c = 0; c < f.Width; c++) + { + AddRune ((Rune)' '); + } + } + else + { + var rowEventArgs = new ListViewRowEventArgs (item); + OnRowRender (rowEventArgs); + + if (rowEventArgs.RowAttribute is { } && current != rowEventArgs.RowAttribute) + { + current = (Attribute)rowEventArgs.RowAttribute; + SetAttribute (current); + } + + if (_allowsMarking) + { + AddRune ( + Source.IsMarked (item) ? AllowsMultipleSelection ? Glyphs.CheckStateChecked : Glyphs.Selected : + AllowsMultipleSelection ? Glyphs.CheckStateUnChecked : Glyphs.UnSelected + ); + AddRune ((Rune)' '); + } + + Source.Render (this, isSelected, item, col, row, f.Width - col, start); + } } - else if (Viewport.Height > 0 && _selected >= Viewport.Y + Viewport.Height) + + return true; + } + + /// + protected override void OnFrameChanged (in Rectangle frame) { EnsureSelectedItemVisible (); } + + /// + protected override void OnHasFocusChanged (bool newHasFocus, View? currentFocused, View? newFocused) + { + if (newHasFocus && _lastSelectedItem != SelectedItem) { - Viewport = Viewport with { Y = _selected - Viewport.Height + 1 }; + EnsureSelectedItemVisible (); } } - /// Marks the if it is not already marked. - /// if the was marked. - public bool MarkUnmarkSelectedItem () + /// + protected override bool OnKeyDown (Key key) { - if (UnmarkAllButSelected ()) + // 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 _)) { - Source.SetMark (SelectedItem, !Source.IsMarked (SelectedItem)); - SetNeedsDraw (); - - return Source.IsMarked (SelectedItem); + return false; } - // BUGBUG: Shouldn't this return Source.IsMarked (SelectedItem) + // Enable user to find & select an item by typing text + if (KeystrokeNavigator.Matcher.IsCompatibleKey (key)) + { + int? newItem = KeystrokeNavigator?.GetNextMatchingItem (SelectedItem ?? null, (char)key); + + if (newItem is { } && newItem != -1) + { + SelectedItem = (int)newItem; + EnsureSelectedItemVisible (); + SetNeedsDraw (); + + return true; + } + } return false; } @@ -457,7 +843,7 @@ public class ListView : View, IDesignable SetFocus (); } - if (_source is null) + if (Source is null) { return false; } @@ -496,21 +882,20 @@ public class ListView : View, IDesignable return true; } - if (me.Position.Y + Viewport.Y >= _source.Count + 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; + SelectedItem = Viewport.Y + me.Position.Y; if (MarkUnmarkSelectedItem ()) { // return true; } - OnSelectedChanged (); SetNeedsDraw (); if (me.Flags == MouseFlags.Button1DoubleClicked) @@ -521,666 +906,20 @@ public class ListView : View, IDesignable return true; } - /// 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) - { - // Do we set lastSelectedItem to -1 here? - return false; //Nothing for us to move to - } - - if (_selected >= _source.Count) - { - // If for some reason we are currently outside of the - // valid values range, we should select the bottommost valid value. - // This can occur if the backing data source changes. - _selected = _source.Count - 1; - OnSelectedChanged (); - SetNeedsDraw (); - } - else if (_selected + 1 < _source.Count) - { - //can move by down by one. - _selected++; - - if (_selected >= Viewport.Y + Viewport.Height) - { - Viewport = Viewport with { Y = Viewport.Y + 1 }; - } - else if (_selected < Viewport.Y) - { - Viewport = Viewport with { Y = _selected }; - } - - OnSelectedChanged (); - SetNeedsDraw (); - } - else if (_selected == 0) - { - OnSelectedChanged (); - SetNeedsDraw (); - } - else if (_selected >= Viewport.Y + Viewport.Height) - { - Viewport = Viewport with { Y = _source.Count - Viewport.Height }; - SetNeedsDraw (); - } - - return true; - } - - /// Changes the to last item in the list, scrolling the list if needed. - /// - public virtual bool MoveEnd () - { - if (_source is { Count: > 0 } && _selected != _source.Count - 1) - { - _selected = _source.Count - 1; - - if (Viewport.Y + _selected > 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) - }; - } - - OnSelectedChanged (); - SetNeedsDraw (); - } - - return true; - } - - /// Changes the to the first item in the list, scrolling the list if needed. - /// - public virtual bool MoveHome () - { - if (_selected != 0) - { - _selected = 0; - Viewport = Viewport with { Y = _selected }; - OnSelectedChanged (); - SetNeedsDraw (); - } - - return true; - } - - /// - /// Changes the to the item just below the bottom of the visible list, scrolling if - /// needed. - /// - /// - public virtual bool MovePageDown () - { - if (_source is null) - { - return true; - } - - int n = _selected + Viewport.Height; - - if (n >= _source.Count) - { - n = _source.Count - 1; - } - - if (n != _selected) - { - _selected = n; - - if (_source.Count >= Viewport.Height) - { - Viewport = Viewport with { Y = _selected }; - } - else - { - Viewport = Viewport with { Y = 0 }; - } - - OnSelectedChanged (); - SetNeedsDraw (); - } - - return true; - } - - /// Changes the to the item at the top of the visible list. - /// - public virtual bool MovePageUp () - { - int n = _selected - Viewport.Height; - - if (n < 0) - { - n = 0; - } - - if (n != _selected) - { - _selected = n; - Viewport = Viewport with { Y = _selected }; - OnSelectedChanged (); - SetNeedsDraw (); - } - - return true; - } - - /// Changes the to the previous item in the list, scrolling the list if needed. - /// - public virtual bool MoveUp () - { - if (_source is null || _source.Count == 0) - { - // Do we set lastSelectedItem to -1 here? - return false; //Nothing for us to move to - } - - if (_selected >= _source.Count) - { - // If for some reason we are currently outside of the - // valid values range, we should select the bottommost valid value. - // This can occur if the backing data source changes. - _selected = _source.Count - 1; - OnSelectedChanged (); - SetNeedsDraw (); - } - else if (_selected > 0) - { - _selected--; - - if (_selected > Source.Count) - { - _selected = Source.Count - 1; - } - - if (_selected < Viewport.Y) - { - Viewport = Viewport with { Y = _selected }; - } - else if (_selected > Viewport.Y + Viewport.Height) - { - Viewport = Viewport with { Y = _selected - Viewport.Height + 1 }; - } - - OnSelectedChanged (); - SetNeedsDraw (); - } - else if (_selected < Viewport.Y) - { - Viewport = Viewport with { Y = _selected }; - SetNeedsDraw (); - } - - return true; - } - /// - protected override bool OnDrawingContent () + protected override void OnViewportChanged (DrawEventArgs e) { SetContentSize (new Size (MaxLength, Source?.Count ?? Viewport.Height)); } + + private void Source_CollectionChanged (object? sender, NotifyCollectionChangedEventArgs e) { - 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; + SetContentSize (new Size (Source?.Length ?? Viewport.Width, Source?.Count ?? Viewport.Width)); - for (var row = 0; row < f.Height; row++, item++) + if (Source is { Count: > 0 } && SelectedItem.HasValue && SelectedItem > Source.Count - 1) { - 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) - { - return false; + SelectedItem = Source.Count - 1; } - object value = _source.ToList () [_selected]; + SetNeedsDraw (); - OpenSelectedItem?.Invoke (this, new ListViewItemEventArgs (_selected, value)); - - // BUGBUG: this should not blindly return true. - return true; - } - - /// - protected override bool OnKeyDown (Key key) - { - // If the key was bound to key command, let normal KeyDown processing happen. This enables overriding the default handling. - // See: https://github.com/gui-cs/Terminal.Gui/issues/3950#issuecomment-2807350939 - if (KeyBindings.TryGet (key, out _)) - { - return false; - } - - // Enable user to find & select an item by typing text - if (KeystrokeNavigator.Matcher.IsCompatibleKey (key)) - { - int? newItem = KeystrokeNavigator?.GetNextMatchingItem (SelectedItem, (char)key); - - if (newItem is { } && newItem != -1) - { - SelectedItem = (int)newItem; - EnsureSelectedItemVisible (); - SetNeedsDraw (); - - return true; - } - } - - return false; - } - - /// 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) - { - object value = _source?.Count > 0 ? _source.ToList () [_selected] : null; - SelectedItemChanged?.Invoke (this, new ListViewItemEventArgs (_selected, value)); - _lastSelectedItem = _selected; - EnsureSelectedItemVisible (); - - return true; - } - - 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; - - /// Sets the source of the to an . - /// An object implementing the IList interface. - /// - /// Use the property to set a new source and use custom - /// rendering. - /// - public void SetSource (ObservableCollection source) - { - if (source is null && Source is not ListWrapper) - { - Source = null; - } - else - { - Source = new ListWrapper (source); - } - } - - /// Sets the source to an value asynchronously. - /// An item implementing the IList interface. - /// - /// Use the property to set a new source and use custom - /// rendering. - /// - public Task SetSourceAsync (ObservableCollection source) - { - return Task.Factory.StartNew ( - () => - { - if (source is null && (Source is null || !(Source is ListWrapper))) - { - Source = null; - } - else - { - Source = new ListWrapper (source); - } - - return source; - }, - CancellationToken.None, - TaskCreationOptions.DenyChildAttach, - TaskScheduler.Default - ); - } - - 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) - { - _source?.Dispose (); - - base.Dispose (disposing); - } - - /// - /// Allow suspending the event from being invoked, - /// - public void SuspendCollectionChangedEvent () - { - if (Source is { }) - { - Source.SuspendCollectionChangedEvent = true; - } - } - - /// - /// Allow resume the event from being invoked, - /// - public void ResumeSuspendCollectionChangedEvent () - { - if (Source is { }) - { - Source.SuspendCollectionChangedEvent = false; - } - } - - /// - public bool EnableForDesign () - { - var source = new ListWrapper (["List Item 1", "List Item two", "List Item Quattro", "Last List Item"]); - Source = source; - - return true; - } -} - -/// -/// 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 (source is { }) - { - _count = source.Count; - _marks = new BitArray (_count); - _source = source; - _source.CollectionChanged += Source_CollectionChanged; - Length = GetMaxLengthItem (); - } - } - - private void Source_CollectionChanged (object sender, NotifyCollectionChangedEventArgs e) - { - if (!SuspendCollectionChangedEvent) - { - CheckAndResizeMarksIfRequired (); - CollectionChanged?.Invoke (sender, e); - } - } - - /// - public event NotifyCollectionChangedEventHandler CollectionChanged; - - /// - public int Count => _source?.Count ?? 0; - - /// - public int Length { get; private set; } - - private bool _suspendCollectionChangedEvent; - - /// - public bool SuspendCollectionChangedEvent - { - get => _suspendCollectionChangedEvent; - set - { - _suspendCollectionChangedEvent = value; - - if (!_suspendCollectionChangedEvent) - { - CheckAndResizeMarksIfRequired (); - } - } - } - - private void CheckAndResizeMarksIfRequired () - { - if (_source != null && _count != _source.Count) - { - _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; - - Length = GetMaxLengthItem (); - } - } - - /// - 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 (_source is { }) - { - object t = _source [item]; - - if (t is null) - { - RenderUstr (container, "", col, line, width); - } - else - { - if (t is string s) - { - RenderUstr (container, s, col, line, width, start); - } - else - { - RenderUstr (container, t.ToString (), col, line, width, start); - } - } - } - } - - /// - public bool IsMarked (int item) - { - if (item >= 0 && item < _count) - { - return _marks [item]; - } - - return false; - } - - /// - public void SetMark (int item, bool value) - { - if (item >= 0 && item < _count) - { - _marks [item] = value; - } - } - - /// - public IList ToList () { return _source; } - - /// - public 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) - { - if (s.StartsWith (search, StringComparison.InvariantCultureIgnoreCase)) - { - return i; - } - } - } - - return -1; - } - - 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]; - int l; - - if (t is string u) - { - l = u.GetColumns (); - } - else if (t is string s) - { - l = s.Length; - } - else - { - l = t.ToString ().Length; - } - - if (l > maxLength) - { - maxLength = l; - } - } - - return maxLength; - } - - 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 (); - - while (width-- > 0) - { - driver.AddRune ((Rune)' '); - } - } - - /// - public void Dispose () - { - if (_source is { }) - { - _source.CollectionChanged -= Source_CollectionChanged; - } + OnCollectionChanged (e); } } diff --git a/Terminal.Gui/Views/ListViewEventArgs.cs b/Terminal.Gui/Views/ListViewEventArgs.cs index 7a718b536..e7a8c2686 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 000000000..5f10b4e06 --- /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 f09532ed2..1c5f298e0 100644 --- a/Terminal.Gui/Views/TableView/TableView.cs +++ b/Terminal.Gui/Views/TableView/TableView.cs @@ -1607,11 +1607,11 @@ public class TableView : View, IDesignable 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 9f3854f7e..fedc501f4 100644 --- a/Terminal.Gui/Views/Toplevel.cs +++ b/Terminal.Gui/Views/Toplevel.cs @@ -82,7 +82,7 @@ public partial class Toplevel : View // 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 4161569b1..ef25662a4 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 69021ab15..39376e87b 100644 --- a/Tests/IntegrationTests/UICatalog/ScenarioTests.cs +++ b/Tests/IntegrationTests/UICatalog/ScenarioTests.cs @@ -317,7 +317,7 @@ public class ScenarioTests : TestsAllViews 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 @@ public class ScenarioTests : TestsAllViews { 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 359fd7a0a..136d4d085 100644 --- a/Tests/TerminalGuiFluentTesting/GuiTestContext.ContextMenu.cs +++ b/Tests/TerminalGuiFluentTesting/GuiTestContext.ContextMenu.cs @@ -24,7 +24,7 @@ public partial class GuiTestContext { // 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 74eafc77e..f96505840 100644 --- a/Tests/TerminalGuiFluentTesting/GuiTestContext.ViewBase.cs +++ b/Tests/TerminalGuiFluentTesting/GuiTestContext.ViewBase.cs @@ -28,11 +28,11 @@ public partial class GuiTestContext /// /// 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 0b8633da7..06d338436 100644 --- a/Tests/UnitTests/SetupFakeApplicationAttribute.cs +++ b/Tests/UnitTests/SetupFakeApplicationAttribute.cs @@ -32,7 +32,10 @@ public class SetupFakeApplicationAttribute : BeforeAfterTestAttribute _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 d42827760..000000000 --- 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 aa3017d51..174788369 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 class SourcesManagerTests } [Fact] - public void Update_WithValidStream_UpdatesSettingsScope () + public void Load_WithValidStream_UpdatesSettingsScope () { // Arrange var sourcesManager = new SourcesManager (); @@ -56,7 +56,7 @@ public class SourcesManagerTests } [Fact] - public void Update_WithInvalidJson_AddsJsonError () + public void Load_WithInvalidJson_AddsJsonError () { // Arrange var sourcesManager = new SourcesManager (); @@ -86,7 +86,7 @@ public class SourcesManagerTests #region Update (FilePath) [Fact] - public void Update_WithNonExistentFile_AddsToSourcesAndReturnsTrue () + public void Load_WithNonExistentFile_AddsToSourcesAndReturnsTrue () { // Arrange var sourcesManager = new SourcesManager (); @@ -104,7 +104,7 @@ public class SourcesManagerTests } [Fact] - public void Update_WithValidFile_UpdatesSettingsScope () + public void Load_WithValidFile_UpdatesSettingsScope () { // Arrange var sourcesManager = new SourcesManager (); @@ -140,7 +140,7 @@ public class SourcesManagerTests } [Fact] - public void Update_WithIOException_RetriesAndFailsGracefully () + public void Load_WithIOException_RetriesAndFailsGracefully () { // Arrange var sourcesManager = new SourcesManager (); @@ -174,7 +174,7 @@ public class SourcesManagerTests #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 class SourcesManagerTests } [Fact] - public void Update_WithValidJson_UpdatesSettingsScope () + public void Load_WithValidJson_UpdatesSettingsScope () { // Arrange var sourcesManager = new SourcesManager (); @@ -381,7 +381,7 @@ public class SourcesManagerTests } [Fact] - public void Update_WhenCalledMultipleTimes_MaintainsLastSourceForLocation () + public void Load_WhenCalledMultipleTimes_MaintainsLastSourceForLocation () { // Arrange var sourcesManager = new SourcesManager (); @@ -401,7 +401,7 @@ public class SourcesManagerTests } [Fact] - public void Update_WithDifferentLocations_AddsAllSourcesToCollection () + public void Load_WithDifferentLocations_AddsAllSourcesToCollection () { // Arrange var sourcesManager = new SourcesManager (); @@ -452,7 +452,7 @@ public class SourcesManagerTests } [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 c3fce7af4..5d3923d50 100644 --- a/Tests/UnitTestsParallelizable/Text/CollectionNavigatorTests.cs +++ b/Tests/UnitTestsParallelizable/Text/CollectionNavigatorTests.cs @@ -42,7 +42,7 @@ public class CollectionNavigatorTests // 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 class CollectionNavigatorTests 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 class CollectionNavigatorTests 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 class CollectionNavigatorTests 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 class CollectionNavigatorTests 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 class CollectionNavigatorTests 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 class CollectionNavigatorTests } [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 class CollectionNavigatorTests 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 class CollectionNavigatorTests 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 class CollectionNavigatorTests 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 35233cc03..5024415aa 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 b8b3e7d43..780ffb94c 100644 --- a/Tests/UnitTestsParallelizable/View/Draw/NeedsDrawTests.cs +++ b/Tests/UnitTestsParallelizable/View/Draw/NeedsDrawTests.cs @@ -130,14 +130,14 @@ public class NeedsDrawTests : FakeDriverBase 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 class NeedsDrawTests : FakeDriverBase 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 class NeedsDrawTests : FakeDriverBase 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 class NeedsDrawTests : FakeDriverBase 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 0078736c5..61aadaea7 100644 --- a/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.PosTypes.cs +++ b/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.PosTypes.cs @@ -597,7 +597,7 @@ public partial class DimAutoTests // 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 b42b0bc9c..4c1da89b5 100644 --- a/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.cs +++ b/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.cs @@ -304,10 +304,10 @@ public partial class DimAutoTests (ITestOutputHelper output) 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 partial class DimAutoTests (ITestOutputHelper output) 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 partial class DimAutoTests (ITestOutputHelper output) 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 partial class DimAutoTests (ITestOutputHelper output) 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 partial class DimAutoTests (ITestOutputHelper output) 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 364012cc3..1f9b24a90 100644 --- a/Tests/UnitTestsParallelizable/View/Layout/FrameTests.cs +++ b/Tests/UnitTestsParallelizable/View/Layout/FrameTests.cs @@ -120,7 +120,7 @@ public class FrameTests 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 class FrameTests 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 class FrameTests 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 class FrameTests 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 class FrameTests 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 f02650d54..a447acd6e 100644 --- a/Tests/UnitTestsParallelizable/View/SubviewTests.cs +++ b/Tests/UnitTestsParallelizable/View/SubviewTests.cs @@ -14,11 +14,11 @@ public class SubViewTests super.SuperViewChanged += (s, e) => { - superRaisedCount++; + superRaisedCount++; }; sub.SuperViewChanged += (s, e) => { - if (e.SuperView is {}) + if (e.SuperView is { }) { subRaisedCount++; } @@ -266,14 +266,14 @@ public class SubViewTests 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 class SubViewTests 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 000000000..29faa1aab --- /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 9b942d681..363412165 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 class ListViewTests [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 class ListViewTests .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 class ListViewTests 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 Constructors_Defaults () + { + var lv = new ListView (); + Assert.Null (lv.Source); + Assert.True (lv.CanFocus); + Assert.Null (lv.SelectedItem); + Assert.False (lv.AllowsMultipleSelection); + + lv = new () { Source = new ListWrapper (["One", "Two", "Three"]) }; + Assert.NotNull (lv.Source); + Assert.Null (lv.SelectedItem); + + lv = new () { Source = new NewListDataSource () }; + Assert.NotNull (lv.Source); + Assert.Null (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); + + 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 ListView_CollectionNavigatorMatcher_KeybindingsOverrideNavigator () + public void KeyBindings_Command () { - ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - ListView lv = new ListView { Source = new ListWrapper (source) }; - - lv.SetFocus (); - - lv.KeyBindings.Add (Key.B, Command.Down); - - Assert.Equal (-1, lv.SelectedItem); - - // Keys should be consumed to move down the navigation i.e. to apricot - Assert.True (lv.NewKeyDownEvent (Key.B)); + 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)); - 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 CollectionNavigatorMatcher_KeybindingsOverrideNavigator () + public void HotKey_Command_SetsFocus () { - ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - ListView lv = new ListView { Source = new ListWrapper (source) }; + var view = new ListView (); - lv.SetFocus (); + view.CanFocus = true; + Assert.False (view.HasFocus); + view.InvokeCommand (Command.HotKey); + Assert.True (view.HasFocus); + } - lv.KeyBindings.Add (Key.B, Command.Down); + [Fact] + public void HotKey_Command_Does_Not_Accept () + { + var listView = new ListView (); + var accepted = false; - Assert.Equal (-1, lv.SelectedItem); + listView.Accepting += OnAccepted; + listView.InvokeCommand (Command.HotKey); - // 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.False (accepted); - Assert.True (lv.NewKeyDownEvent (Key.B)); + 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 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.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.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 4a9975244..7e72ebe5f 100644 --- a/Tests/UnitTestsParallelizable/Views/NumericUpDownTests.cs +++ b/Tests/UnitTestsParallelizable/Views/NumericUpDownTests.cs @@ -112,7 +112,7 @@ public class NumericUpDownTests 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 class NumericUpDownTests 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 class NumericUpDownTests 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 class NumericUpDownTests 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 class NumericUpDownTests 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 b999b1fc0..298cf6ff5 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 4a549d5ce..e260cad34 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 830ec3c19..437d34ffc 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 8a459e0a0..d7fd2c0b0 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 a4e98b8c5..165913925 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 7fde06795..2c5c2391a 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 e416dd754..43dec8e87 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 8acb4573f..c25192060 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