From b05373f9689128d3e56029af287f84a40e82f4a7 Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 12 Nov 2025 12:29:17 -0700 Subject: [PATCH 01/17] 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 --- .../Drivers/WindowsKeyConverterTests.cs | 636 ++++++++++++++++++ 1 file changed, 636 insertions(+) create mode 100644 Tests/UnitTestsParallelizable/Drivers/WindowsKeyConverterTests.cs diff --git a/Tests/UnitTestsParallelizable/Drivers/WindowsKeyConverterTests.cs b/Tests/UnitTestsParallelizable/Drivers/WindowsKeyConverterTests.cs new file mode 100644 index 0000000000..d03f04d627 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/WindowsKeyConverterTests.cs @@ -0,0 +1,636 @@ +namespace UnitTests_Parallelizable.DriverTests; + +public class WindowsKeyConverterTests +{ + private readonly WindowsKeyConverter _converter = new (); + + #region ToKey Tests - Basic Characters + + [Theory] + [InlineData ('a', ConsoleKey.A, false, false, false, KeyCode.A)] // lowercase a + [InlineData ('A', ConsoleKey.A, true, false, false, KeyCode.A | KeyCode.ShiftMask)] // uppercase A + [InlineData ('z', ConsoleKey.Z, false, false, false, KeyCode.Z)] + [InlineData ('Z', ConsoleKey.Z, true, false, false, KeyCode.Z | KeyCode.ShiftMask)] + public void ToKey_LetterKeys_ReturnsExpectedKeyCode ( + char unicodeChar, + ConsoleKey consoleKey, + bool shift, + bool alt, + bool ctrl, + KeyCode expectedKeyCode) + { + // Arrange + WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, shift, alt, ctrl); + + // Act + Key result = _converter.ToKey (inputRecord); + + // Assert + Assert.Equal (expectedKeyCode, result.KeyCode); + } + + [Theory] + [InlineData ('0', ConsoleKey.D0, false, false, false, KeyCode.D0)] + [InlineData ('1', ConsoleKey.D1, false, false, false, KeyCode.D1)] + [InlineData ('9', ConsoleKey.D9, false, false, false, KeyCode.D9)] + public void ToKey_NumberKeys_ReturnsExpectedKeyCode ( + char unicodeChar, + ConsoleKey consoleKey, + bool shift, + bool alt, + bool ctrl, + KeyCode expectedKeyCode) + { + // Arrange + WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, shift, alt, ctrl); + + // Act + Key result = _converter.ToKey (inputRecord); + + // Assert + Assert.Equal (expectedKeyCode, result.KeyCode); + } + + #endregion + + #region ToKey Tests - Modifiers + + [Theory] + [InlineData ('a', ConsoleKey.A, false, false, true, KeyCode.A | KeyCode.CtrlMask)] // Ctrl+A + [InlineData ('A', ConsoleKey.A, true, false, true, KeyCode.A | KeyCode.ShiftMask | KeyCode.CtrlMask)] // Ctrl+Shift+A (Windows keeps ShiftMask) + [InlineData ('a', ConsoleKey.A, false, true, false, KeyCode.A | KeyCode.AltMask)] // Alt+A + [InlineData ('A', ConsoleKey.A, true, true, false, KeyCode.A | KeyCode.ShiftMask | KeyCode.AltMask)] // Alt+Shift+A + [InlineData ('a', ConsoleKey.A, false, true, true, KeyCode.A | KeyCode.CtrlMask | KeyCode.AltMask)] // Ctrl+Alt+A + public void ToKey_WithModifiers_ReturnsExpectedKeyCode ( + char unicodeChar, + ConsoleKey consoleKey, + bool shift, + bool alt, + bool ctrl, + KeyCode expectedKeyCode) + { + // Arrange + WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, shift, alt, ctrl); + + // Act + Key result = _converter.ToKey (inputRecord); + + // Assert + Assert.Equal (expectedKeyCode, result.KeyCode); + } + + #endregion + + #region ToKey Tests - Special Keys + + [Theory] + [InlineData (ConsoleKey.Enter, KeyCode.Enter)] + [InlineData (ConsoleKey.Escape, KeyCode.Esc)] + [InlineData (ConsoleKey.Tab, KeyCode.Tab)] + [InlineData (ConsoleKey.Backspace, KeyCode.Backspace)] + [InlineData (ConsoleKey.Delete, KeyCode.Delete)] + [InlineData (ConsoleKey.Insert, KeyCode.Insert)] + [InlineData (ConsoleKey.Home, KeyCode.Home)] + [InlineData (ConsoleKey.End, KeyCode.End)] + [InlineData (ConsoleKey.PageUp, KeyCode.PageUp)] + [InlineData (ConsoleKey.PageDown, KeyCode.PageDown)] + [InlineData (ConsoleKey.UpArrow, KeyCode.CursorUp)] + [InlineData (ConsoleKey.DownArrow, KeyCode.CursorDown)] + [InlineData (ConsoleKey.LeftArrow, KeyCode.CursorLeft)] + [InlineData (ConsoleKey.RightArrow, KeyCode.CursorRight)] + public void ToKey_SpecialKeys_ReturnsExpectedKeyCode (ConsoleKey consoleKey, KeyCode expectedKeyCode) + { + // Arrange + char unicodeChar = consoleKey switch + { + ConsoleKey.Enter => '\r', + ConsoleKey.Escape => '\u001B', + ConsoleKey.Tab => '\t', + ConsoleKey.Backspace => '\b', + _ => '\0' + }; + WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, false, false, false); + + // Act + Key result = _converter.ToKey (inputRecord); + + // Assert + Assert.Equal (expectedKeyCode, result.KeyCode); + } + + [Theory] + [InlineData (ConsoleKey.F1, KeyCode.F1)] + [InlineData (ConsoleKey.F2, KeyCode.F2)] + [InlineData (ConsoleKey.F3, KeyCode.F3)] + [InlineData (ConsoleKey.F4, KeyCode.F4)] + [InlineData (ConsoleKey.F5, KeyCode.F5)] + [InlineData (ConsoleKey.F6, KeyCode.F6)] + [InlineData (ConsoleKey.F7, KeyCode.F7)] + [InlineData (ConsoleKey.F8, KeyCode.F8)] + [InlineData (ConsoleKey.F9, KeyCode.F9)] + [InlineData (ConsoleKey.F10, KeyCode.F10)] + [InlineData (ConsoleKey.F11, KeyCode.F11)] + [InlineData (ConsoleKey.F12, KeyCode.F12)] + public void ToKey_FunctionKeys_ReturnsExpectedKeyCode (ConsoleKey consoleKey, KeyCode expectedKeyCode) + { + // Arrange + WindowsConsole.InputRecord inputRecord = CreateInputRecord ('\0', consoleKey, false, false, false); + + // Act + Key result = _converter.ToKey (inputRecord); + + // Assert + Assert.Equal (expectedKeyCode, result.KeyCode); + } + + #endregion + + #region ToKey Tests - VK_PACKET (Unicode/IME) + + [Theory] + [InlineData ('?')] // Chinese character + [InlineData ('?')] // Japanese character + [InlineData ('?')] // Korean character + [InlineData ('ι')] // Accented character + [InlineData ('€')] // Euro symbol + [InlineData ('?')] // Greek character + public void ToKey_VKPacket_Unicode_ReturnsExpectedCharacter (char unicodeChar) + { + // Arrange + WindowsConsole.InputRecord inputRecord = CreateVKPacketInputRecord (unicodeChar); + + // Act + Key result = _converter.ToKey (inputRecord); + + // Assert + Assert.Equal ((KeyCode)unicodeChar, result.KeyCode); + } + + [Fact] + public void ToKey_VKPacket_ZeroChar_ReturnsNull () + { + // Arrange + WindowsConsole.InputRecord inputRecord = CreateVKPacketInputRecord ('\0'); + + // Act + Key result = _converter.ToKey (inputRecord); + + // Assert + Assert.Equal (KeyCode.Null, result.KeyCode); + } + + #endregion + + #region ToKey Tests - OEM Keys + + [Theory] + [InlineData (';', ConsoleKey.Oem1, false, (KeyCode)';')] + [InlineData (':', ConsoleKey.Oem1, true, (KeyCode)':')] + [InlineData ('/', ConsoleKey.Oem2, false, (KeyCode)'/')] + [InlineData ('?', ConsoleKey.Oem2, true, (KeyCode)'?')] + [InlineData (',', ConsoleKey.OemComma, false, (KeyCode)',')] + [InlineData ('<', ConsoleKey.OemComma, true, (KeyCode)'<')] + [InlineData ('.', ConsoleKey.OemPeriod, false, (KeyCode)'.')] + [InlineData ('>', ConsoleKey.OemPeriod, true, (KeyCode)'>')] + [InlineData ('=', ConsoleKey.OemPlus, false, (KeyCode)'=')] // Un-shifted OemPlus is '=' + [InlineData ('+', ConsoleKey.OemPlus, true, (KeyCode)'+')] // Shifted OemPlus is '+' + [InlineData ('-', ConsoleKey.OemMinus, false, (KeyCode)'-')] + [InlineData ('_', ConsoleKey.OemMinus, true, (KeyCode)'_')] // Shifted OemMinus is '_' + public void ToKey_OEMKeys_ReturnsExpectedKeyCode ( + char unicodeChar, + ConsoleKey consoleKey, + bool shift, + KeyCode expectedKeyCode) + { + // Arrange + WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, shift, false, false); + + // Act + Key result = _converter.ToKey (inputRecord); + + // Assert + Assert.Equal (expectedKeyCode, result.KeyCode); + } + + #endregion + + #region ToKey Tests - NumPad + + [Theory] + [InlineData ('0', ConsoleKey.NumPad0, KeyCode.D0)] + [InlineData ('1', ConsoleKey.NumPad1, KeyCode.D1)] + [InlineData ('5', ConsoleKey.NumPad5, KeyCode.D5)] + [InlineData ('9', ConsoleKey.NumPad9, KeyCode.D9)] + public void ToKey_NumPadKeys_ReturnsExpectedKeyCode ( + char unicodeChar, + ConsoleKey consoleKey, + KeyCode expectedKeyCode) + { + // Arrange + WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, false, false, false); + + // Act + Key result = _converter.ToKey (inputRecord); + + // Assert + Assert.Equal (expectedKeyCode, result.KeyCode); + } + + [Theory] + [InlineData ('*', ConsoleKey.Multiply, (KeyCode)'*')] + [InlineData ('+', ConsoleKey.Add, (KeyCode)'+')] + [InlineData ('-', ConsoleKey.Subtract, (KeyCode)'-')] + [InlineData ('.', ConsoleKey.Decimal, (KeyCode)'.')] + [InlineData ('/', ConsoleKey.Divide, (KeyCode)'/')] + public void ToKey_NumPadOperators_ReturnsExpectedKeyCode ( + char unicodeChar, + ConsoleKey consoleKey, + KeyCode expectedKeyCode) + { + // Arrange + WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, false, false, false); + + // Act + Key result = _converter.ToKey (inputRecord); + + // Assert + Assert.Equal (expectedKeyCode, result.KeyCode); + } + + #endregion + + #region ToKey Tests - Null/Empty + + [Fact] + public void ToKey_NullKey_ReturnsEmpty () + { + // Arrange + WindowsConsole.InputRecord inputRecord = CreateInputRecord ('\0', ConsoleKey.None, false, false, false); + + // Act + Key result = _converter.ToKey (inputRecord); + + // Assert + Assert.Equal (Key.Empty, result); + } + + #endregion + + #region ToKeyInfo Tests - Basic Keys + + [Theory] + [InlineData (KeyCode.A, ConsoleKey.A, 'a')] + [InlineData (KeyCode.A | KeyCode.ShiftMask, ConsoleKey.A, 'A')] + [InlineData (KeyCode.Z, ConsoleKey.Z, 'z')] + [InlineData (KeyCode.Z | KeyCode.ShiftMask, ConsoleKey.Z, 'Z')] + public void ToKeyInfo_LetterKeys_ReturnsExpectedInputRecord ( + KeyCode keyCode, + ConsoleKey expectedConsoleKey, + char expectedChar) + { + // Arrange + var key = new Key (keyCode); + + // Act + WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); + + // Assert + Assert.Equal (WindowsConsole.EventType.Key, result.EventType); + Assert.Equal ((VK)expectedConsoleKey, result.KeyEvent.wVirtualKeyCode); + Assert.Equal (expectedChar, result.KeyEvent.UnicodeChar); + Assert.True (result.KeyEvent.bKeyDown); + Assert.Equal ((ushort)1, result.KeyEvent.wRepeatCount); + } + + [Theory] + [InlineData (KeyCode.D0, ConsoleKey.D0, '0')] + [InlineData (KeyCode.D1, ConsoleKey.D1, '1')] + [InlineData (KeyCode.D9, ConsoleKey.D9, '9')] + public void ToKeyInfo_NumberKeys_ReturnsExpectedInputRecord ( + KeyCode keyCode, + ConsoleKey expectedConsoleKey, + char expectedChar) + { + // Arrange + var key = new Key (keyCode); + + // Act + WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); + + // Assert + Assert.Equal ((VK)expectedConsoleKey, result.KeyEvent.wVirtualKeyCode); + Assert.Equal (expectedChar, result.KeyEvent.UnicodeChar); + } + + #endregion + + #region ToKeyInfo Tests - Special Keys + + [Theory] + [InlineData (KeyCode.Enter, ConsoleKey.Enter, '\r')] + [InlineData (KeyCode.Esc, ConsoleKey.Escape, '\u001B')] + [InlineData (KeyCode.Tab, ConsoleKey.Tab, '\t')] + [InlineData (KeyCode.Backspace, ConsoleKey.Backspace, '\b')] + [InlineData (KeyCode.Space, ConsoleKey.Spacebar, ' ')] + public void ToKeyInfo_SpecialKeys_ReturnsExpectedInputRecord ( + KeyCode keyCode, + ConsoleKey expectedConsoleKey, + char expectedChar) + { + // Arrange + var key = new Key (keyCode); + + // Act + WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); + + // Assert + Assert.Equal ((VK)expectedConsoleKey, result.KeyEvent.wVirtualKeyCode); + Assert.Equal (expectedChar, result.KeyEvent.UnicodeChar); + } + + [Theory] + [InlineData (KeyCode.Delete, ConsoleKey.Delete)] + [InlineData (KeyCode.Insert, ConsoleKey.Insert)] + [InlineData (KeyCode.Home, ConsoleKey.Home)] + [InlineData (KeyCode.End, ConsoleKey.End)] + [InlineData (KeyCode.PageUp, ConsoleKey.PageUp)] + [InlineData (KeyCode.PageDown, ConsoleKey.PageDown)] + [InlineData (KeyCode.CursorUp, ConsoleKey.UpArrow)] + [InlineData (KeyCode.CursorDown, ConsoleKey.DownArrow)] + [InlineData (KeyCode.CursorLeft, ConsoleKey.LeftArrow)] + [InlineData (KeyCode.CursorRight, ConsoleKey.RightArrow)] + public void ToKeyInfo_NavigationKeys_ReturnsExpectedInputRecord (KeyCode keyCode, ConsoleKey expectedConsoleKey) + { + // Arrange + var key = new Key (keyCode); + + // Act + WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); + + // Assert + Assert.Equal ((VK)expectedConsoleKey, result.KeyEvent.wVirtualKeyCode); + } + + [Theory] + [InlineData (KeyCode.F1, ConsoleKey.F1)] + [InlineData (KeyCode.F5, ConsoleKey.F5)] + [InlineData (KeyCode.F10, ConsoleKey.F10)] + [InlineData (KeyCode.F12, ConsoleKey.F12)] + public void ToKeyInfo_FunctionKeys_ReturnsExpectedInputRecord (KeyCode keyCode, ConsoleKey expectedConsoleKey) + { + // Arrange + var key = new Key (keyCode); + + // Act + WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); + + // Assert + Assert.Equal ((VK)expectedConsoleKey, result.KeyEvent.wVirtualKeyCode); + } + + #endregion + + #region ToKeyInfo Tests - Modifiers + + [Theory] + [InlineData (KeyCode.A | KeyCode.ShiftMask, WindowsConsole.ControlKeyState.ShiftPressed)] + [InlineData (KeyCode.A | KeyCode.CtrlMask, WindowsConsole.ControlKeyState.LeftControlPressed)] + [InlineData (KeyCode.A | KeyCode.AltMask, WindowsConsole.ControlKeyState.LeftAltPressed)] + [InlineData ( + KeyCode.A | KeyCode.CtrlMask | KeyCode.AltMask, + WindowsConsole.ControlKeyState.LeftControlPressed | WindowsConsole.ControlKeyState.LeftAltPressed)] + [InlineData ( + KeyCode.A | KeyCode.ShiftMask | KeyCode.CtrlMask | KeyCode.AltMask, + WindowsConsole.ControlKeyState.ShiftPressed | WindowsConsole.ControlKeyState.LeftControlPressed | + WindowsConsole.ControlKeyState.LeftAltPressed)] + public void ToKeyInfo_WithModifiers_ReturnsExpectedControlKeyState ( + KeyCode keyCode, + WindowsConsole.ControlKeyState expectedState) + { + // Arrange + var key = new Key (keyCode); + + // Act + WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); + + // Assert + Assert.Equal (expectedState, result.KeyEvent.dwControlKeyState); + } + + #endregion + + #region ToKeyInfo Tests - Scan Codes + + [Theory] + [InlineData (KeyCode.A, 30)] + [InlineData (KeyCode.Enter, 28)] + [InlineData (KeyCode.Esc, 1)] + [InlineData (KeyCode.Space, 57)] + [InlineData (KeyCode.F1, 59)] + [InlineData (KeyCode.F10, 68)] + [InlineData (KeyCode.CursorUp, 72)] + [InlineData (KeyCode.Home, 71)] + public void ToKeyInfo_ScanCodes_ReturnsExpectedScanCode (KeyCode keyCode, ushort expectedScanCode) + { + // Arrange + var key = new Key (keyCode); + + // Act + WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); + + // Assert + Assert.Equal (expectedScanCode, result.KeyEvent.wVirtualScanCode); + } + + #endregion + + #region Round-Trip Tests + + [Theory] + [InlineData (KeyCode.A)] + [InlineData (KeyCode.A | KeyCode.ShiftMask)] + [InlineData (KeyCode.A | KeyCode.CtrlMask)] + [InlineData (KeyCode.Enter)] + [InlineData (KeyCode.F1)] + [InlineData (KeyCode.CursorUp)] + [InlineData (KeyCode.Delete)] + [InlineData (KeyCode.D5)] + [InlineData (KeyCode.Space)] + public void RoundTrip_ToKeyInfo_ToKey_PreservesKeyCode (KeyCode originalKeyCode) + { + // Arrange + var originalKey = new Key (originalKeyCode); + + // Act + WindowsConsole.InputRecord inputRecord = _converter.ToKeyInfo (originalKey); + Key roundTrippedKey = _converter.ToKey (inputRecord); + + // Assert + Assert.Equal (originalKeyCode, roundTrippedKey.KeyCode); + } + + [Theory] + [InlineData ('a', ConsoleKey.A, false, false, false)] + [InlineData ('A', ConsoleKey.A, true, false, false)] + [InlineData ('a', ConsoleKey.A, false, false, true)] // Ctrl+A + [InlineData ('0', ConsoleKey.D0, false, false, false)] + public void RoundTrip_ToKey_ToKeyInfo_PreservesData ( + char unicodeChar, + ConsoleKey consoleKey, + bool shift, + bool alt, + bool ctrl) + { + // Arrange + WindowsConsole.InputRecord originalRecord = CreateInputRecord (unicodeChar, consoleKey, shift, alt, ctrl); + + // Act + Key key = _converter.ToKey (originalRecord); + WindowsConsole.InputRecord roundTrippedRecord = _converter.ToKeyInfo (key); + + // Assert + Assert.Equal ((VK)consoleKey, roundTrippedRecord.KeyEvent.wVirtualKeyCode); + + // Check modifiers match + var expectedState = WindowsConsole.ControlKeyState.NoControlKeyPressed; + + if (shift) + { + expectedState |= WindowsConsole.ControlKeyState.ShiftPressed; + } + + if (alt) + { + expectedState |= WindowsConsole.ControlKeyState.LeftAltPressed; + } + + if (ctrl) + { + expectedState |= WindowsConsole.ControlKeyState.LeftControlPressed; + } + + Assert.True (roundTrippedRecord.KeyEvent.dwControlKeyState.HasFlag (expectedState)); + } + + #endregion + + #region CapsLock/NumLock Tests + + [Theory] + [InlineData ('a', ConsoleKey.A, false, true)] // CapsLock on, no shift + [InlineData ('A', ConsoleKey.A, true, true)] // CapsLock on, shift (should be lowercase from mapping) + public void ToKey_WithCapsLock_ReturnsExpectedKeyCode ( + char unicodeChar, + ConsoleKey consoleKey, + bool shift, + bool capsLock) + { + // Arrange + WindowsConsole.InputRecord inputRecord = CreateInputRecordWithLockStates ( + unicodeChar, + consoleKey, + shift, + false, + false, + capsLock, + false, + false); + + // Act + Key result = _converter.ToKey (inputRecord); + + // Assert + // The mapping should handle CapsLock properly via WindowsKeyHelper.MapKey + Assert.NotEqual (KeyCode.Null, result.KeyCode); + } + + #endregion + + #region Helper Methods + + private static WindowsConsole.InputRecord CreateInputRecord ( + char unicodeChar, + ConsoleKey consoleKey, + bool shift, + bool alt, + bool ctrl) + { + return CreateInputRecordWithLockStates (unicodeChar, consoleKey, shift, alt, ctrl, false, false, false); + } + + private static WindowsConsole.InputRecord CreateInputRecordWithLockStates ( + char unicodeChar, + ConsoleKey consoleKey, + bool shift, + bool alt, + bool ctrl, + bool capsLock, + bool numLock, + bool scrollLock) + { + var controlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed; + + if (shift) + { + controlKeyState |= WindowsConsole.ControlKeyState.ShiftPressed; + } + + if (alt) + { + controlKeyState |= WindowsConsole.ControlKeyState.LeftAltPressed; + } + + if (ctrl) + { + controlKeyState |= WindowsConsole.ControlKeyState.LeftControlPressed; + } + + if (capsLock) + { + controlKeyState |= WindowsConsole.ControlKeyState.CapslockOn; + } + + if (numLock) + { + controlKeyState |= WindowsConsole.ControlKeyState.NumlockOn; + } + + if (scrollLock) + { + controlKeyState |= WindowsConsole.ControlKeyState.ScrolllockOn; + } + + return new () + { + EventType = WindowsConsole.EventType.Key, + KeyEvent = new () + { + bKeyDown = true, + wRepeatCount = 1, + wVirtualKeyCode = (VK)consoleKey, + wVirtualScanCode = 0, + UnicodeChar = unicodeChar, + dwControlKeyState = controlKeyState + } + }; + } + + private static WindowsConsole.InputRecord CreateVKPacketInputRecord (char unicodeChar) + { + return new () + { + EventType = WindowsConsole.EventType.Key, + KeyEvent = new () + { + bKeyDown = true, + wRepeatCount = 1, + wVirtualKeyCode = VK.PACKET, + wVirtualScanCode = 0, + UnicodeChar = unicodeChar, + dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed + } + }; + } + + #endregion +} From 6b0e8828e3d910c290154bc3c7aecf0056ce0ac6 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 18:05:25 -0700 Subject: [PATCH 02/17] 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. --- .../UICatalog/Scenarios/ListViewWithSelection.cs | 8 ++++---- Terminal.Gui/Views/IListDataSource.cs | 4 ++-- Terminal.Gui/Views/ListView.cs | 12 ++++++------ Tests/UnitTests/Views/ListViewTests.cs | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Examples/UICatalog/Scenarios/ListViewWithSelection.cs b/Examples/UICatalog/Scenarios/ListViewWithSelection.cs index 874d061a58..51839199b1 100644 --- a/Examples/UICatalog/Scenarios/ListViewWithSelection.cs +++ b/Examples/UICatalog/Scenarios/ListViewWithSelection.cs @@ -237,7 +237,7 @@ public void Render ( int col, int line, int width, - int start = 0 + int viewportXOffset = 0 ) { container.Move (col, line); @@ -247,7 +247,7 @@ public void Render ( string.Format ("{{0,{0}}}", -_nameColumnWidth), Scenarios [item].GetName () ); - RenderUstr (container, $"{s} ({Scenarios [item].GetDescription ()})", col, line, width, start); + RenderUstr (container, $"{s} ({Scenarios [item].GetDescription ()})", col, line, width, viewportXOffset); } public void SetMark (int item, bool value) @@ -288,10 +288,10 @@ Scenarios [i].GetName () } // A slightly adapted method from: https://github.com/gui-cs/Terminal.Gui/blob/fc1faba7452ccbdf49028ac49f0c9f0f42bbae91/Terminal.Gui/Views/ListView.cs#L433-L461 - private void RenderUstr (View view, string ustr, int col, int line, int width, int start = 0) + private void RenderUstr (View view, string ustr, int col, int line, int width, int viewportXOffset = 0) { var used = 0; - int index = start; + int index = viewportXOffset; while (index < ustr.Length) { diff --git a/Terminal.Gui/Views/IListDataSource.cs b/Terminal.Gui/Views/IListDataSource.cs index 37f1a63d60..14f12def59 100644 --- a/Terminal.Gui/Views/IListDataSource.cs +++ b/Terminal.Gui/Views/IListDataSource.cs @@ -37,7 +37,7 @@ public interface IListDataSource : IDisposable /// The column where the rendering will start /// The line where the rendering will be done. /// The width that must be filled out. - /// The index of the string to be displayed. + /// The index of the string to be displayed. /// /// The default color will be set before this method is invoked, and will be based on whether the item is selected /// or not. @@ -49,7 +49,7 @@ void Render ( int col, int line, int width, - int start = 0 + int viewportXOffset = 0 ); /// Flags the item as marked. diff --git a/Terminal.Gui/Views/ListView.cs b/Terminal.Gui/Views/ListView.cs index b933af9e03..6ca55dc8ef 100644 --- a/Terminal.Gui/Views/ListView.cs +++ b/Terminal.Gui/Views/ListView.cs @@ -1044,10 +1044,10 @@ public void Render ( int col, int line, int width, - int start = 0 + int viewportXOffset = 0 ) { - container.Move (Math.Max (col - start, 0), line); + container.Move (Math.Max (col - viewportXOffset, 0), line); if (_source is { }) { @@ -1061,11 +1061,11 @@ public void Render ( { if (t is string s) { - RenderUstr (container, s, col, line, width, start); + RenderUstr (container, s, col, line, width, viewportXOffset); } else { - RenderUstr (container, t.ToString (), col, line, width, start); + RenderUstr (container, t.ToString (), col, line, width, viewportXOffset); } } } @@ -1161,9 +1161,9 @@ private int GetMaxLengthItem () return maxLength; } - private void RenderUstr (View driver, string ustr, int col, int line, int width, int start = 0) + private void RenderUstr (View driver, string ustr, int col, int line, int width, int viewportXOffset = 0) { - string str = start > ustr.GetColumns () ? string.Empty : ustr.Substring (Math.Min (start, ustr.ToRunes ().Length - 1)); + string str = viewportXOffset > ustr.GetColumns () ? string.Empty : ustr.Substring (Math.Min (viewportXOffset, ustr.ToRunes ().Length - 1)); string u = TextFormatter.ClipAndJustify (str, width, Alignment.Start); driver.AddStr (u); width -= u.GetColumns (); diff --git a/Tests/UnitTests/Views/ListViewTests.cs b/Tests/UnitTests/Views/ListViewTests.cs index d428277608..1d58198bc9 100644 --- a/Tests/UnitTests/Views/ListViewTests.cs +++ b/Tests/UnitTests/Views/ListViewTests.cs @@ -756,7 +756,7 @@ public void Render ( int col, int line, int width, - int start = 0 + int viewportXOffset = 0 ) { throw new NotImplementedException (); From 7cd37fe81b69d370bdf547cb14acbb9bc23a11ba Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 18:09:34 -0700 Subject: [PATCH 03/17] Remove WindowsKeyConverterTests class that was added by mistake --- .../Drivers/WindowsKeyConverterTests.cs | 636 ------------------ 1 file changed, 636 deletions(-) delete mode 100644 Tests/UnitTestsParallelizable/Drivers/WindowsKeyConverterTests.cs diff --git a/Tests/UnitTestsParallelizable/Drivers/WindowsKeyConverterTests.cs b/Tests/UnitTestsParallelizable/Drivers/WindowsKeyConverterTests.cs deleted file mode 100644 index d03f04d627..0000000000 --- a/Tests/UnitTestsParallelizable/Drivers/WindowsKeyConverterTests.cs +++ /dev/null @@ -1,636 +0,0 @@ -namespace UnitTests_Parallelizable.DriverTests; - -public class WindowsKeyConverterTests -{ - private readonly WindowsKeyConverter _converter = new (); - - #region ToKey Tests - Basic Characters - - [Theory] - [InlineData ('a', ConsoleKey.A, false, false, false, KeyCode.A)] // lowercase a - [InlineData ('A', ConsoleKey.A, true, false, false, KeyCode.A | KeyCode.ShiftMask)] // uppercase A - [InlineData ('z', ConsoleKey.Z, false, false, false, KeyCode.Z)] - [InlineData ('Z', ConsoleKey.Z, true, false, false, KeyCode.Z | KeyCode.ShiftMask)] - public void ToKey_LetterKeys_ReturnsExpectedKeyCode ( - char unicodeChar, - ConsoleKey consoleKey, - bool shift, - bool alt, - bool ctrl, - KeyCode expectedKeyCode) - { - // Arrange - WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, shift, alt, ctrl); - - // Act - Key result = _converter.ToKey (inputRecord); - - // Assert - Assert.Equal (expectedKeyCode, result.KeyCode); - } - - [Theory] - [InlineData ('0', ConsoleKey.D0, false, false, false, KeyCode.D0)] - [InlineData ('1', ConsoleKey.D1, false, false, false, KeyCode.D1)] - [InlineData ('9', ConsoleKey.D9, false, false, false, KeyCode.D9)] - public void ToKey_NumberKeys_ReturnsExpectedKeyCode ( - char unicodeChar, - ConsoleKey consoleKey, - bool shift, - bool alt, - bool ctrl, - KeyCode expectedKeyCode) - { - // Arrange - WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, shift, alt, ctrl); - - // Act - Key result = _converter.ToKey (inputRecord); - - // Assert - Assert.Equal (expectedKeyCode, result.KeyCode); - } - - #endregion - - #region ToKey Tests - Modifiers - - [Theory] - [InlineData ('a', ConsoleKey.A, false, false, true, KeyCode.A | KeyCode.CtrlMask)] // Ctrl+A - [InlineData ('A', ConsoleKey.A, true, false, true, KeyCode.A | KeyCode.ShiftMask | KeyCode.CtrlMask)] // Ctrl+Shift+A (Windows keeps ShiftMask) - [InlineData ('a', ConsoleKey.A, false, true, false, KeyCode.A | KeyCode.AltMask)] // Alt+A - [InlineData ('A', ConsoleKey.A, true, true, false, KeyCode.A | KeyCode.ShiftMask | KeyCode.AltMask)] // Alt+Shift+A - [InlineData ('a', ConsoleKey.A, false, true, true, KeyCode.A | KeyCode.CtrlMask | KeyCode.AltMask)] // Ctrl+Alt+A - public void ToKey_WithModifiers_ReturnsExpectedKeyCode ( - char unicodeChar, - ConsoleKey consoleKey, - bool shift, - bool alt, - bool ctrl, - KeyCode expectedKeyCode) - { - // Arrange - WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, shift, alt, ctrl); - - // Act - Key result = _converter.ToKey (inputRecord); - - // Assert - Assert.Equal (expectedKeyCode, result.KeyCode); - } - - #endregion - - #region ToKey Tests - Special Keys - - [Theory] - [InlineData (ConsoleKey.Enter, KeyCode.Enter)] - [InlineData (ConsoleKey.Escape, KeyCode.Esc)] - [InlineData (ConsoleKey.Tab, KeyCode.Tab)] - [InlineData (ConsoleKey.Backspace, KeyCode.Backspace)] - [InlineData (ConsoleKey.Delete, KeyCode.Delete)] - [InlineData (ConsoleKey.Insert, KeyCode.Insert)] - [InlineData (ConsoleKey.Home, KeyCode.Home)] - [InlineData (ConsoleKey.End, KeyCode.End)] - [InlineData (ConsoleKey.PageUp, KeyCode.PageUp)] - [InlineData (ConsoleKey.PageDown, KeyCode.PageDown)] - [InlineData (ConsoleKey.UpArrow, KeyCode.CursorUp)] - [InlineData (ConsoleKey.DownArrow, KeyCode.CursorDown)] - [InlineData (ConsoleKey.LeftArrow, KeyCode.CursorLeft)] - [InlineData (ConsoleKey.RightArrow, KeyCode.CursorRight)] - public void ToKey_SpecialKeys_ReturnsExpectedKeyCode (ConsoleKey consoleKey, KeyCode expectedKeyCode) - { - // Arrange - char unicodeChar = consoleKey switch - { - ConsoleKey.Enter => '\r', - ConsoleKey.Escape => '\u001B', - ConsoleKey.Tab => '\t', - ConsoleKey.Backspace => '\b', - _ => '\0' - }; - WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, false, false, false); - - // Act - Key result = _converter.ToKey (inputRecord); - - // Assert - Assert.Equal (expectedKeyCode, result.KeyCode); - } - - [Theory] - [InlineData (ConsoleKey.F1, KeyCode.F1)] - [InlineData (ConsoleKey.F2, KeyCode.F2)] - [InlineData (ConsoleKey.F3, KeyCode.F3)] - [InlineData (ConsoleKey.F4, KeyCode.F4)] - [InlineData (ConsoleKey.F5, KeyCode.F5)] - [InlineData (ConsoleKey.F6, KeyCode.F6)] - [InlineData (ConsoleKey.F7, KeyCode.F7)] - [InlineData (ConsoleKey.F8, KeyCode.F8)] - [InlineData (ConsoleKey.F9, KeyCode.F9)] - [InlineData (ConsoleKey.F10, KeyCode.F10)] - [InlineData (ConsoleKey.F11, KeyCode.F11)] - [InlineData (ConsoleKey.F12, KeyCode.F12)] - public void ToKey_FunctionKeys_ReturnsExpectedKeyCode (ConsoleKey consoleKey, KeyCode expectedKeyCode) - { - // Arrange - WindowsConsole.InputRecord inputRecord = CreateInputRecord ('\0', consoleKey, false, false, false); - - // Act - Key result = _converter.ToKey (inputRecord); - - // Assert - Assert.Equal (expectedKeyCode, result.KeyCode); - } - - #endregion - - #region ToKey Tests - VK_PACKET (Unicode/IME) - - [Theory] - [InlineData ('?')] // Chinese character - [InlineData ('?')] // Japanese character - [InlineData ('?')] // Korean character - [InlineData ('ι')] // Accented character - [InlineData ('€')] // Euro symbol - [InlineData ('?')] // Greek character - public void ToKey_VKPacket_Unicode_ReturnsExpectedCharacter (char unicodeChar) - { - // Arrange - WindowsConsole.InputRecord inputRecord = CreateVKPacketInputRecord (unicodeChar); - - // Act - Key result = _converter.ToKey (inputRecord); - - // Assert - Assert.Equal ((KeyCode)unicodeChar, result.KeyCode); - } - - [Fact] - public void ToKey_VKPacket_ZeroChar_ReturnsNull () - { - // Arrange - WindowsConsole.InputRecord inputRecord = CreateVKPacketInputRecord ('\0'); - - // Act - Key result = _converter.ToKey (inputRecord); - - // Assert - Assert.Equal (KeyCode.Null, result.KeyCode); - } - - #endregion - - #region ToKey Tests - OEM Keys - - [Theory] - [InlineData (';', ConsoleKey.Oem1, false, (KeyCode)';')] - [InlineData (':', ConsoleKey.Oem1, true, (KeyCode)':')] - [InlineData ('/', ConsoleKey.Oem2, false, (KeyCode)'/')] - [InlineData ('?', ConsoleKey.Oem2, true, (KeyCode)'?')] - [InlineData (',', ConsoleKey.OemComma, false, (KeyCode)',')] - [InlineData ('<', ConsoleKey.OemComma, true, (KeyCode)'<')] - [InlineData ('.', ConsoleKey.OemPeriod, false, (KeyCode)'.')] - [InlineData ('>', ConsoleKey.OemPeriod, true, (KeyCode)'>')] - [InlineData ('=', ConsoleKey.OemPlus, false, (KeyCode)'=')] // Un-shifted OemPlus is '=' - [InlineData ('+', ConsoleKey.OemPlus, true, (KeyCode)'+')] // Shifted OemPlus is '+' - [InlineData ('-', ConsoleKey.OemMinus, false, (KeyCode)'-')] - [InlineData ('_', ConsoleKey.OemMinus, true, (KeyCode)'_')] // Shifted OemMinus is '_' - public void ToKey_OEMKeys_ReturnsExpectedKeyCode ( - char unicodeChar, - ConsoleKey consoleKey, - bool shift, - KeyCode expectedKeyCode) - { - // Arrange - WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, shift, false, false); - - // Act - Key result = _converter.ToKey (inputRecord); - - // Assert - Assert.Equal (expectedKeyCode, result.KeyCode); - } - - #endregion - - #region ToKey Tests - NumPad - - [Theory] - [InlineData ('0', ConsoleKey.NumPad0, KeyCode.D0)] - [InlineData ('1', ConsoleKey.NumPad1, KeyCode.D1)] - [InlineData ('5', ConsoleKey.NumPad5, KeyCode.D5)] - [InlineData ('9', ConsoleKey.NumPad9, KeyCode.D9)] - public void ToKey_NumPadKeys_ReturnsExpectedKeyCode ( - char unicodeChar, - ConsoleKey consoleKey, - KeyCode expectedKeyCode) - { - // Arrange - WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, false, false, false); - - // Act - Key result = _converter.ToKey (inputRecord); - - // Assert - Assert.Equal (expectedKeyCode, result.KeyCode); - } - - [Theory] - [InlineData ('*', ConsoleKey.Multiply, (KeyCode)'*')] - [InlineData ('+', ConsoleKey.Add, (KeyCode)'+')] - [InlineData ('-', ConsoleKey.Subtract, (KeyCode)'-')] - [InlineData ('.', ConsoleKey.Decimal, (KeyCode)'.')] - [InlineData ('/', ConsoleKey.Divide, (KeyCode)'/')] - public void ToKey_NumPadOperators_ReturnsExpectedKeyCode ( - char unicodeChar, - ConsoleKey consoleKey, - KeyCode expectedKeyCode) - { - // Arrange - WindowsConsole.InputRecord inputRecord = CreateInputRecord (unicodeChar, consoleKey, false, false, false); - - // Act - Key result = _converter.ToKey (inputRecord); - - // Assert - Assert.Equal (expectedKeyCode, result.KeyCode); - } - - #endregion - - #region ToKey Tests - Null/Empty - - [Fact] - public void ToKey_NullKey_ReturnsEmpty () - { - // Arrange - WindowsConsole.InputRecord inputRecord = CreateInputRecord ('\0', ConsoleKey.None, false, false, false); - - // Act - Key result = _converter.ToKey (inputRecord); - - // Assert - Assert.Equal (Key.Empty, result); - } - - #endregion - - #region ToKeyInfo Tests - Basic Keys - - [Theory] - [InlineData (KeyCode.A, ConsoleKey.A, 'a')] - [InlineData (KeyCode.A | KeyCode.ShiftMask, ConsoleKey.A, 'A')] - [InlineData (KeyCode.Z, ConsoleKey.Z, 'z')] - [InlineData (KeyCode.Z | KeyCode.ShiftMask, ConsoleKey.Z, 'Z')] - public void ToKeyInfo_LetterKeys_ReturnsExpectedInputRecord ( - KeyCode keyCode, - ConsoleKey expectedConsoleKey, - char expectedChar) - { - // Arrange - var key = new Key (keyCode); - - // Act - WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); - - // Assert - Assert.Equal (WindowsConsole.EventType.Key, result.EventType); - Assert.Equal ((VK)expectedConsoleKey, result.KeyEvent.wVirtualKeyCode); - Assert.Equal (expectedChar, result.KeyEvent.UnicodeChar); - Assert.True (result.KeyEvent.bKeyDown); - Assert.Equal ((ushort)1, result.KeyEvent.wRepeatCount); - } - - [Theory] - [InlineData (KeyCode.D0, ConsoleKey.D0, '0')] - [InlineData (KeyCode.D1, ConsoleKey.D1, '1')] - [InlineData (KeyCode.D9, ConsoleKey.D9, '9')] - public void ToKeyInfo_NumberKeys_ReturnsExpectedInputRecord ( - KeyCode keyCode, - ConsoleKey expectedConsoleKey, - char expectedChar) - { - // Arrange - var key = new Key (keyCode); - - // Act - WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); - - // Assert - Assert.Equal ((VK)expectedConsoleKey, result.KeyEvent.wVirtualKeyCode); - Assert.Equal (expectedChar, result.KeyEvent.UnicodeChar); - } - - #endregion - - #region ToKeyInfo Tests - Special Keys - - [Theory] - [InlineData (KeyCode.Enter, ConsoleKey.Enter, '\r')] - [InlineData (KeyCode.Esc, ConsoleKey.Escape, '\u001B')] - [InlineData (KeyCode.Tab, ConsoleKey.Tab, '\t')] - [InlineData (KeyCode.Backspace, ConsoleKey.Backspace, '\b')] - [InlineData (KeyCode.Space, ConsoleKey.Spacebar, ' ')] - public void ToKeyInfo_SpecialKeys_ReturnsExpectedInputRecord ( - KeyCode keyCode, - ConsoleKey expectedConsoleKey, - char expectedChar) - { - // Arrange - var key = new Key (keyCode); - - // Act - WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); - - // Assert - Assert.Equal ((VK)expectedConsoleKey, result.KeyEvent.wVirtualKeyCode); - Assert.Equal (expectedChar, result.KeyEvent.UnicodeChar); - } - - [Theory] - [InlineData (KeyCode.Delete, ConsoleKey.Delete)] - [InlineData (KeyCode.Insert, ConsoleKey.Insert)] - [InlineData (KeyCode.Home, ConsoleKey.Home)] - [InlineData (KeyCode.End, ConsoleKey.End)] - [InlineData (KeyCode.PageUp, ConsoleKey.PageUp)] - [InlineData (KeyCode.PageDown, ConsoleKey.PageDown)] - [InlineData (KeyCode.CursorUp, ConsoleKey.UpArrow)] - [InlineData (KeyCode.CursorDown, ConsoleKey.DownArrow)] - [InlineData (KeyCode.CursorLeft, ConsoleKey.LeftArrow)] - [InlineData (KeyCode.CursorRight, ConsoleKey.RightArrow)] - public void ToKeyInfo_NavigationKeys_ReturnsExpectedInputRecord (KeyCode keyCode, ConsoleKey expectedConsoleKey) - { - // Arrange - var key = new Key (keyCode); - - // Act - WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); - - // Assert - Assert.Equal ((VK)expectedConsoleKey, result.KeyEvent.wVirtualKeyCode); - } - - [Theory] - [InlineData (KeyCode.F1, ConsoleKey.F1)] - [InlineData (KeyCode.F5, ConsoleKey.F5)] - [InlineData (KeyCode.F10, ConsoleKey.F10)] - [InlineData (KeyCode.F12, ConsoleKey.F12)] - public void ToKeyInfo_FunctionKeys_ReturnsExpectedInputRecord (KeyCode keyCode, ConsoleKey expectedConsoleKey) - { - // Arrange - var key = new Key (keyCode); - - // Act - WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); - - // Assert - Assert.Equal ((VK)expectedConsoleKey, result.KeyEvent.wVirtualKeyCode); - } - - #endregion - - #region ToKeyInfo Tests - Modifiers - - [Theory] - [InlineData (KeyCode.A | KeyCode.ShiftMask, WindowsConsole.ControlKeyState.ShiftPressed)] - [InlineData (KeyCode.A | KeyCode.CtrlMask, WindowsConsole.ControlKeyState.LeftControlPressed)] - [InlineData (KeyCode.A | KeyCode.AltMask, WindowsConsole.ControlKeyState.LeftAltPressed)] - [InlineData ( - KeyCode.A | KeyCode.CtrlMask | KeyCode.AltMask, - WindowsConsole.ControlKeyState.LeftControlPressed | WindowsConsole.ControlKeyState.LeftAltPressed)] - [InlineData ( - KeyCode.A | KeyCode.ShiftMask | KeyCode.CtrlMask | KeyCode.AltMask, - WindowsConsole.ControlKeyState.ShiftPressed | WindowsConsole.ControlKeyState.LeftControlPressed | - WindowsConsole.ControlKeyState.LeftAltPressed)] - public void ToKeyInfo_WithModifiers_ReturnsExpectedControlKeyState ( - KeyCode keyCode, - WindowsConsole.ControlKeyState expectedState) - { - // Arrange - var key = new Key (keyCode); - - // Act - WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); - - // Assert - Assert.Equal (expectedState, result.KeyEvent.dwControlKeyState); - } - - #endregion - - #region ToKeyInfo Tests - Scan Codes - - [Theory] - [InlineData (KeyCode.A, 30)] - [InlineData (KeyCode.Enter, 28)] - [InlineData (KeyCode.Esc, 1)] - [InlineData (KeyCode.Space, 57)] - [InlineData (KeyCode.F1, 59)] - [InlineData (KeyCode.F10, 68)] - [InlineData (KeyCode.CursorUp, 72)] - [InlineData (KeyCode.Home, 71)] - public void ToKeyInfo_ScanCodes_ReturnsExpectedScanCode (KeyCode keyCode, ushort expectedScanCode) - { - // Arrange - var key = new Key (keyCode); - - // Act - WindowsConsole.InputRecord result = _converter.ToKeyInfo (key); - - // Assert - Assert.Equal (expectedScanCode, result.KeyEvent.wVirtualScanCode); - } - - #endregion - - #region Round-Trip Tests - - [Theory] - [InlineData (KeyCode.A)] - [InlineData (KeyCode.A | KeyCode.ShiftMask)] - [InlineData (KeyCode.A | KeyCode.CtrlMask)] - [InlineData (KeyCode.Enter)] - [InlineData (KeyCode.F1)] - [InlineData (KeyCode.CursorUp)] - [InlineData (KeyCode.Delete)] - [InlineData (KeyCode.D5)] - [InlineData (KeyCode.Space)] - public void RoundTrip_ToKeyInfo_ToKey_PreservesKeyCode (KeyCode originalKeyCode) - { - // Arrange - var originalKey = new Key (originalKeyCode); - - // Act - WindowsConsole.InputRecord inputRecord = _converter.ToKeyInfo (originalKey); - Key roundTrippedKey = _converter.ToKey (inputRecord); - - // Assert - Assert.Equal (originalKeyCode, roundTrippedKey.KeyCode); - } - - [Theory] - [InlineData ('a', ConsoleKey.A, false, false, false)] - [InlineData ('A', ConsoleKey.A, true, false, false)] - [InlineData ('a', ConsoleKey.A, false, false, true)] // Ctrl+A - [InlineData ('0', ConsoleKey.D0, false, false, false)] - public void RoundTrip_ToKey_ToKeyInfo_PreservesData ( - char unicodeChar, - ConsoleKey consoleKey, - bool shift, - bool alt, - bool ctrl) - { - // Arrange - WindowsConsole.InputRecord originalRecord = CreateInputRecord (unicodeChar, consoleKey, shift, alt, ctrl); - - // Act - Key key = _converter.ToKey (originalRecord); - WindowsConsole.InputRecord roundTrippedRecord = _converter.ToKeyInfo (key); - - // Assert - Assert.Equal ((VK)consoleKey, roundTrippedRecord.KeyEvent.wVirtualKeyCode); - - // Check modifiers match - var expectedState = WindowsConsole.ControlKeyState.NoControlKeyPressed; - - if (shift) - { - expectedState |= WindowsConsole.ControlKeyState.ShiftPressed; - } - - if (alt) - { - expectedState |= WindowsConsole.ControlKeyState.LeftAltPressed; - } - - if (ctrl) - { - expectedState |= WindowsConsole.ControlKeyState.LeftControlPressed; - } - - Assert.True (roundTrippedRecord.KeyEvent.dwControlKeyState.HasFlag (expectedState)); - } - - #endregion - - #region CapsLock/NumLock Tests - - [Theory] - [InlineData ('a', ConsoleKey.A, false, true)] // CapsLock on, no shift - [InlineData ('A', ConsoleKey.A, true, true)] // CapsLock on, shift (should be lowercase from mapping) - public void ToKey_WithCapsLock_ReturnsExpectedKeyCode ( - char unicodeChar, - ConsoleKey consoleKey, - bool shift, - bool capsLock) - { - // Arrange - WindowsConsole.InputRecord inputRecord = CreateInputRecordWithLockStates ( - unicodeChar, - consoleKey, - shift, - false, - false, - capsLock, - false, - false); - - // Act - Key result = _converter.ToKey (inputRecord); - - // Assert - // The mapping should handle CapsLock properly via WindowsKeyHelper.MapKey - Assert.NotEqual (KeyCode.Null, result.KeyCode); - } - - #endregion - - #region Helper Methods - - private static WindowsConsole.InputRecord CreateInputRecord ( - char unicodeChar, - ConsoleKey consoleKey, - bool shift, - bool alt, - bool ctrl) - { - return CreateInputRecordWithLockStates (unicodeChar, consoleKey, shift, alt, ctrl, false, false, false); - } - - private static WindowsConsole.InputRecord CreateInputRecordWithLockStates ( - char unicodeChar, - ConsoleKey consoleKey, - bool shift, - bool alt, - bool ctrl, - bool capsLock, - bool numLock, - bool scrollLock) - { - var controlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed; - - if (shift) - { - controlKeyState |= WindowsConsole.ControlKeyState.ShiftPressed; - } - - if (alt) - { - controlKeyState |= WindowsConsole.ControlKeyState.LeftAltPressed; - } - - if (ctrl) - { - controlKeyState |= WindowsConsole.ControlKeyState.LeftControlPressed; - } - - if (capsLock) - { - controlKeyState |= WindowsConsole.ControlKeyState.CapslockOn; - } - - if (numLock) - { - controlKeyState |= WindowsConsole.ControlKeyState.NumlockOn; - } - - if (scrollLock) - { - controlKeyState |= WindowsConsole.ControlKeyState.ScrolllockOn; - } - - return new () - { - EventType = WindowsConsole.EventType.Key, - KeyEvent = new () - { - bKeyDown = true, - wRepeatCount = 1, - wVirtualKeyCode = (VK)consoleKey, - wVirtualScanCode = 0, - UnicodeChar = unicodeChar, - dwControlKeyState = controlKeyState - } - }; - } - - private static WindowsConsole.InputRecord CreateVKPacketInputRecord (char unicodeChar) - { - return new () - { - EventType = WindowsConsole.EventType.Key, - KeyEvent = new () - { - bKeyDown = true, - wRepeatCount = 1, - wVirtualKeyCode = VK.PACKET, - wVirtualScanCode = 0, - UnicodeChar = unicodeChar, - dwControlKeyState = WindowsConsole.ControlKeyState.NoControlKeyPressed - } - }; - } - - #endregion -} From 8b9d47870b97bdbfc04102dae3f0a3912a949dd2 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 15 Nov 2025 12:19:53 -0700 Subject: [PATCH 04/17] 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. --- .../Scenarios/ListViewWithSelection.cs | 8 +- Terminal.Gui/Views/IListDataSource.cs | 107 +- Terminal.Gui/Views/ListView.cs | 1074 +++++++---------- Terminal.Gui/Views/ListWrapper.cs | 266 ++++ Terminal.sln.DotSettings | 1 + Tests/UnitTests/Views/ListViewTests.cs | 2 +- .../Views/IListDataSourceTests.cs | 494 ++++++++ 7 files changed, 1248 insertions(+), 704 deletions(-) create mode 100644 Terminal.Gui/Views/ListWrapper.cs create mode 100644 Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs diff --git a/Examples/UICatalog/Scenarios/ListViewWithSelection.cs b/Examples/UICatalog/Scenarios/ListViewWithSelection.cs index 51839199b1..a20eb67af7 100644 --- a/Examples/UICatalog/Scenarios/ListViewWithSelection.cs +++ b/Examples/UICatalog/Scenarios/ListViewWithSelection.cs @@ -237,7 +237,7 @@ public void Render ( int col, int line, int width, - int viewportXOffset = 0 + int viewportX = 0 ) { container.Move (col, line); @@ -247,7 +247,7 @@ public void Render ( string.Format ("{{0,{0}}}", -_nameColumnWidth), Scenarios [item].GetName () ); - RenderUstr (container, $"{s} ({Scenarios [item].GetDescription ()})", col, line, width, viewportXOffset); + RenderUstr (container, $"{s} ({Scenarios [item].GetDescription ()})", col, line, width, viewportX); } public void SetMark (int item, bool value) @@ -288,10 +288,10 @@ Scenarios [i].GetName () } // A slightly adapted method from: https://github.com/gui-cs/Terminal.Gui/blob/fc1faba7452ccbdf49028ac49f0c9f0f42bbae91/Terminal.Gui/Views/ListView.cs#L433-L461 - private void RenderUstr (View view, string ustr, int col, int line, int width, int viewportXOffset = 0) + private void RenderUstr (View view, string ustr, int col, int line, int width, int viewportX = 0) { var used = 0; - int index = viewportXOffset; + int index = viewportX; while (index < ustr.Length) { diff --git a/Terminal.Gui/Views/IListDataSource.cs b/Terminal.Gui/Views/IListDataSource.cs index 14f12def59..7b2b97942a 100644 --- a/Terminal.Gui/Views/IListDataSource.cs +++ b/Terminal.Gui/Views/IListDataSource.cs @@ -4,43 +4,68 @@ namespace Terminal.Gui.Views; -/// Implement to provide custom rendering for a . +/// +/// Provides data and rendering for . Implement this interface to provide custom rendering +/// or to wrap custom data sources. +/// +/// +/// +/// The default implementation is which renders items using +/// . +/// +/// +/// Implementors must manage their own marking state and raise when the +/// underlying data changes. +/// +/// public interface IListDataSource : IDisposable { /// - /// Event to raise when an item is added, removed, or moved, or the entire list is refreshed. + /// Raised when items are added, removed, moved, or the entire collection is refreshed. /// + /// + /// subscribes to this event to update its display and content size when the data + /// changes. Implementations should raise this event whenever the underlying collection changes, unless + /// is . + /// event NotifyCollectionChangedEventHandler CollectionChanged; - /// Returns the number of elements to display + /// Gets the number of items in the data source. int Count { get; } - /// Returns the maximum length of elements to display - int Length { get; } - - /// - /// Allow suspending the event from being invoked, - /// if , otherwise is . - /// - bool SuspendCollectionChangedEvent { get; set; } - - /// Should return whether the specified item is currently marked. - /// , if marked, otherwise. - /// Item index. + /// Determines whether the specified item is marked. + /// The zero-based index of the item. + /// if the item is marked; otherwise . + /// + /// calls this method to determine whether to render the item with a mark indicator when + /// is . + /// bool IsMarked (int item); - /// This method is invoked to render a specified item, the method should cover the entire provided width. - /// The render. - /// The list view to render. - /// Describes whether the item being rendered is currently selected by the user. - /// The index of the item to render, zero for the first item and so on. - /// The column where the rendering will start - /// The line where the rendering will be done. - /// The width that must be filled out. - /// The index of the string to be displayed. + /// Gets the width in columns of the widest item in the data source. + /// + /// uses this value to set its horizontal content size for scrolling. + /// + int Length { get; } + + /// Renders the specified item to the . + /// The to render to. + /// + /// if the item is currently selected; otherwise . + /// + /// The zero-based index of the item to render. + /// The column in where rendering starts. + /// The line in where rendering occurs. + /// The width available for rendering. + /// The horizontal scroll offset. /// - /// The default color will be set before this method is invoked, and will be based on whether the item is selected - /// or not. + /// + /// calls this method for each visible item during rendering. The color scheme will be + /// set based on selection state before this method is called. + /// + /// + /// Implementations must fill the entire to avoid rendering artifacts. + /// /// void Render ( ListView listView, @@ -49,15 +74,33 @@ void Render ( int col, int line, int width, - int viewportXOffset = 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 6ca55dc8ef..41953434d4 100644 --- a/Terminal.Gui/Views/ListView.cs +++ b/Terminal.Gui/Views/ListView.cs @@ -1,3 +1,4 @@ +#nullable enable using System.Collections; using System.Collections.ObjectModel; using System.Collections.Specialized; @@ -16,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. /// /// @@ -42,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. @@ -62,22 +59,8 @@ public ListView () // Things this view knows how to do // - AddCommand (Command.Up, (ctx) => - { - if (RaiseSelecting (ctx) == true) - { - return true; - } - return MoveUp (); - }); - AddCommand (Command.Down, (ctx) => - { - if (RaiseSelecting (ctx) == true) - { - return true; - } - return MoveDown (); - }); + AddCommand (Command.Up, ctx => RaiseSelecting (ctx) == true || MoveUp ()); + AddCommand (Command.Down, ctx => RaiseSelecting (ctx) == true || MoveDown ()); // TODO: add RaiseSelecting to all of these AddCommand (Command.ScrollUp, () => ScrollVertical (-1)); @@ -90,66 +73,67 @@ public ListView () AddCommand (Command.ScrollRight, () => ScrollHorizontal (1)); // Accept (Enter key) - Raise Accept event - DO NOT advance state - AddCommand (Command.Accept, (ctx) => - { - if (RaiseAccepting (ctx) == true) - { - return true; - } - - if (OnOpenSelectedItem ()) - { - return true; - } + AddCommand ( + Command.Accept, + ctx => + { + if (RaiseAccepting (ctx) == true) + { + return true; + } - return false; - }); + return OnOpenSelectedItem (); + }); // Select (Space key and single-click) - If markable, change mark and raise Select event - AddCommand (Command.Select, (ctx) => - { - if (_allowsMarking) - { - if (RaiseSelecting (ctx) == true) - { - return true; - } - - if (MarkUnmarkSelectedItem ()) - { - return true; - } - } + AddCommand ( + Command.Select, + ctx => + { + if (!_allowsMarking) + { + return false; + } - return false; - }); + if (RaiseSelecting (ctx) == true) + { + return true; + } + return MarkUnmarkSelectedItem (); + }); // Hotkey - If none set, select and raise Select event. SetFocus. - DO NOT raise Accept - AddCommand (Command.HotKey, (ctx) => - { - if (SelectedItem == -1) - { - SelectedItem = 0; - if (RaiseSelecting (ctx) == true) - { - return true; - - } - } - - return !SetFocus (); - }); - - AddCommand (Command.SelectAll, (ctx) => - { - if (ctx is not CommandContext keyCommandContext) - { - return false; - } - - return keyCommandContext.Binding.Data is { } && MarkAll ((bool)keyCommandContext.Binding.Data); - }); + AddCommand ( + Command.HotKey, + ctx => + { + if (SelectedItem != -1) + { + return !SetFocus (); + } + + SelectedItem = 0; + + if (RaiseSelecting (ctx) == true) + { + return true; + } + + return !SetFocus (); + }); + + AddCommand ( + Command.SelectAll, + ctx => + { + if (ctx is not CommandContext keyCommandContext) + { + return false; + } + + return keyCommandContext.Binding.Data is { } && MarkAll ((bool)keyCommandContext.Binding.Data); + }); // Default keybindings for all ListViews KeyBindings.Add (Key.CursorUp, Command.Up); @@ -168,23 +152,29 @@ public ListView () KeyBindings.Add (Key.End, Command.End); // Key.Space is already bound to Command.Select; this gives us select then move down - KeyBindings.Add (Key.Space.WithShift, [Command.Select, Command.Down]); + KeyBindings.Add (Key.Space.WithShift, Command.Select, Command.Down); // Use the form of Add that lets us pass context to the handler KeyBindings.Add (Key.A.WithCtrl, new KeyBinding ([Command.SelectAll], true)); KeyBindings.Add (Key.U.WithCtrl, new KeyBinding ([Command.SelectAll], false)); } - /// - protected override void OnViewportChanged (DrawEventArgs e) - { - SetContentSize (new Size (MaxLength, _source?.Count ?? Viewport.Height)); - } + private bool _allowsMarking; + + private bool _allowsMultipleSelection; + + private int _selectedItem = -1; + private int _lastSelectedItem = -1; - /// - 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. @@ -216,10 +206,10 @@ public bool AllowsMultipleSelection if (Source is { } && !_allowsMultipleSelection) { - // Clear all selections except selected + // Clear all selections except selected for (var i = 0; i < Source.Count; i++) { - if (Source.IsMarked (i) && i != _selected) + if (Source.IsMarked (i) && i != SelectedItem) { Source.SetMark (i, false); } @@ -230,11 +220,34 @@ public bool AllowsMultipleSelection } } + /// + /// Event to raise when an item is added, removed, or moved, or the entire list is refreshed. + /// + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + /// Ensures the selected item is always visible on the screen. + public void EnsureSelectedItemVisible () + { + if (SelectedItem == -1) + { + return; + } + + if (SelectedItem < Viewport.Y) + { + Viewport = Viewport with { Y = SelectedItem }; + } + else if (Viewport.Height > 0 && SelectedItem >= Viewport.Y + Viewport.Height) + { + Viewport = Viewport with { Y = SelectedItem - 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. @@ -243,7 +256,7 @@ public int LeftItem get => Viewport.X; set { - if (_source is null) + if (Source is null) { return; } @@ -258,99 +271,6 @@ public int LeftItem } } - /// Gets the widest item in the list. - public int MaxLength => _source?.Length ?? 0; - - /// Gets or sets the index of the currently selected item. - /// The selected item. - public int SelectedItem - { - get => _selected; - set - { - if (_source is null || _source.Count == 0) - { - return; - } - - if (value < -1 || value >= _source.Count) - { - throw new ArgumentException ("value"); - } - - _selected = value; - OnSelectedChanged (); - } - } - - /// Gets or sets the backing this , enabling custom rendering. - /// The source. - /// Use to set a new source. - public IListDataSource Source - { - get => _source; - set - { - if (_source == value) - { - return; - } - - _source?.Dispose (); - _source = value; - - if (_source is { }) - { - _source.CollectionChanged += Source_CollectionChanged; - } - - SetContentSize (new Size (_source?.Length ?? Viewport.Width, _source?.Count ?? Viewport.Width)); - if (IsInitialized) - { - // Viewport = Viewport with { Y = 0 }; - } - - KeystrokeNavigator.Collection = _source?.ToList (); - _selected = -1; - _lastSelectedItem = -1; - SetNeedsDraw (); - } - } - - - private void Source_CollectionChanged (object sender, NotifyCollectionChangedEventArgs e) - { - SetContentSize (new Size (_source?.Length ?? Viewport.Width, _source?.Count ?? Viewport.Width)); - - if (Source is { Count: > 0 } && _selected > Source.Count - 1) - { - SelectedItem = Source.Count - 1; - } - - SetNeedsDraw (); - - OnCollectionChanged (e); - } - - /// Gets or sets the index of the item that will appear at the top of the . - /// - /// This a helper property for accessing listView.Viewport.Y. - /// - /// The top item. - public int TopItem - { - get => Viewport.Y; - set - { - if (_source is null) - { - return; - } - - Viewport = Viewport with { Y = value }; - } - } - /// /// If and are both , /// marks all items. @@ -366,205 +286,71 @@ public bool MarkAll (bool mark) if (AllowsMultipleSelection) { - for (var i = 0; i < Source.Count; i++) + for (var i = 0; i < Source?.Count; i++) { Source.SetMark (i, mark); } + return true; } return false; } - /// - /// If and are both , - /// unmarks all marked items other than . - /// - /// if unmarking was successful. - public bool UnmarkAllButSelected () - { - if (!_allowsMarking) - { - return false; - } - - if (!AllowsMultipleSelection) - { - for (var i = 0; i < Source.Count; i++) - { - if (Source.IsMarked (i) && i != _selected) - { - Source.SetMark (i, false); - - return true; - } - } - } - - return true; - } - - /// Ensures the selected item is always visible on the screen. - public void EnsureSelectedItemVisible () - { - if (_selected == -1) - { - return; - } - if (_selected < Viewport.Y) - { - Viewport = Viewport with { Y = _selected }; - } - else if (Viewport.Height > 0 && _selected >= Viewport.Y + Viewport.Height) - { - Viewport = Viewport with { Y = _selected - Viewport.Height + 1 }; - } - } - /// Marks the if it is not already marked. /// if the was marked. public bool MarkUnmarkSelectedItem () { - if (UnmarkAllButSelected ()) - { - Source.SetMark (SelectedItem, !Source.IsMarked (SelectedItem)); - SetNeedsDraw (); - - return Source.IsMarked (SelectedItem); - } - - // BUGBUG: Shouldn't this return Source.IsMarked (SelectedItem) - - return false; - } - - /// - protected override bool OnMouseEvent (MouseEventArgs me) - { - if (!me.Flags.HasFlag (MouseFlags.Button1Clicked) - && !me.Flags.HasFlag (MouseFlags.Button1DoubleClicked) - && me.Flags != MouseFlags.WheeledDown - && me.Flags != MouseFlags.WheeledUp - && me.Flags != MouseFlags.WheeledRight - && me.Flags != MouseFlags.WheeledLeft) - { - return false; - } - - if (!HasFocus && CanFocus) - { - SetFocus (); - } - - if (_source is null) + if (Source is null || !UnmarkAllButSelected ()) { return false; } - if (me.Flags == MouseFlags.WheeledDown) - { - if (Viewport.Y + Viewport.Height < GetContentSize ().Height) - { - ScrollVertical (1); - } - - return true; - } - - if (me.Flags == MouseFlags.WheeledUp) - { - ScrollVertical (-1); - - return true; - } - - if (me.Flags == MouseFlags.WheeledRight) - { - if (Viewport.X + Viewport.Width < GetContentSize ().Width) - { - ScrollHorizontal (1); - } - - return true; - } - - if (me.Flags == MouseFlags.WheeledLeft) - { - ScrollHorizontal (-1); - - return true; - } - - if (me.Position.Y + Viewport.Y >= _source.Count - || me.Position.Y + Viewport.Y < 0 - || me.Position.Y + Viewport.Y > Viewport.Y + Viewport.Height) - { - return true; - } - - _selected = Viewport.Y + me.Position.Y; - - if (MarkUnmarkSelectedItem ()) - { - // return true; - } - - OnSelectedChanged (); + Source.SetMark (SelectedItem, !Source.IsMarked (SelectedItem)); SetNeedsDraw (); - if (me.Flags == MouseFlags.Button1DoubleClicked) - { - return InvokeCommand (Command.Accept) is true; - } + return Source.IsMarked (SelectedItem); - return true; + // BUGBUG: Shouldn't this return Source.IsMarked (SelectedItem) } + /// Gets the widest item in the list. + public int MaxLength => Source?.Length ?? 0; + /// Changes the to the next item in the list, scrolling the list if needed. /// public virtual bool MoveDown () { - if (_source is null || _source.Count == 0) + if (Source is null || Source.Count == 0) { // Do we set lastSelectedItem to -1 here? return false; //Nothing for us to move to } - if (_selected >= _source.Count) + if (SelectedItem >= Source.Count) { - // If for some reason we are currently outside of the + // If for some reason we are currently outside the // valid values range, we should select the bottommost valid value. // This can occur if the backing data source changes. - _selected = _source.Count - 1; - OnSelectedChanged (); - SetNeedsDraw (); + SelectedItem = Source.Count - 1; } - else if (_selected + 1 < _source.Count) + else if (SelectedItem + 1 < Source.Count) { //can move by down by one. - _selected++; + SelectedItem++; - if (_selected >= Viewport.Y + Viewport.Height) + if (SelectedItem >= Viewport.Y + Viewport.Height) { Viewport = Viewport with { Y = Viewport.Y + 1 }; } - else if (_selected < Viewport.Y) + else if (SelectedItem < Viewport.Y) { - Viewport = Viewport with { Y = _selected }; + Viewport = Viewport with { Y = SelectedItem }; } - - OnSelectedChanged (); - SetNeedsDraw (); - } - else if (_selected == 0) - { - OnSelectedChanged (); - SetNeedsDraw (); } - else if (_selected >= Viewport.Y + Viewport.Height) + else if (SelectedItem >= Viewport.Y + Viewport.Height) { - Viewport = Viewport with { Y = _source.Count - Viewport.Height }; - SetNeedsDraw (); + Viewport = Viewport with { Y = Source.Count - Viewport.Height }; } return true; @@ -574,22 +360,19 @@ public virtual bool MoveDown () /// public virtual bool MoveEnd () { - if (_source is { Count: > 0 } && _selected != _source.Count - 1) + if (Source is { Count: > 0 } && SelectedItem != Source.Count - 1) { - _selected = _source.Count - 1; + SelectedItem = Source.Count - 1; - if (Viewport.Y + _selected > Viewport.Height - 1) + if (Viewport.Y + SelectedItem > Viewport.Height - 1) { Viewport = Viewport with { - Y = _selected < Viewport.Height - 1 - ? Math.Max (Viewport.Height - _selected + 1, 0) - : Math.Max (_selected - Viewport.Height + 1, 0) + Y = SelectedItem < Viewport.Height - 1 + ? Math.Max (Viewport.Height - SelectedItem + 1, 0) + : Math.Max (SelectedItem - Viewport.Height + 1, 0) }; } - - OnSelectedChanged (); - SetNeedsDraw (); } return true; @@ -599,12 +382,10 @@ public virtual bool MoveEnd () /// public virtual bool MoveHome () { - if (_selected != 0) + if (SelectedItem != 0) { - _selected = 0; - Viewport = Viewport with { Y = _selected }; - OnSelectedChanged (); - SetNeedsDraw (); + SelectedItem = 0; + Viewport = Viewport with { Y = SelectedItem }; } return true; @@ -617,33 +398,30 @@ public virtual bool MoveHome () /// public virtual bool MovePageDown () { - if (_source is null) + if (Source is null) { return true; } - int n = _selected + Viewport.Height; + int n = SelectedItem + Viewport.Height; - if (n >= _source.Count) + if (n >= Source.Count) { - n = _source.Count - 1; + n = Source.Count - 1; } - if (n != _selected) + if (n != SelectedItem) { - _selected = n; + SelectedItem = n; - if (_source.Count >= Viewport.Height) + if (Source.Count >= Viewport.Height) { - Viewport = Viewport with { Y = _selected }; + Viewport = Viewport with { Y = SelectedItem }; } else { Viewport = Viewport with { Y = 0 }; } - - OnSelectedChanged (); - SetNeedsDraw (); } return true; @@ -653,19 +431,17 @@ public virtual bool MovePageDown () /// public virtual bool MovePageUp () { - int n = _selected - Viewport.Height; + int n = SelectedItem - Viewport.Height; if (n < 0) { n = 0; } - if (n != _selected) + if (n != SelectedItem) { - _selected = n; - Viewport = Viewport with { Y = _selected }; - OnSelectedChanged (); - SetNeedsDraw (); + SelectedItem = n; + Viewport = Viewport with { Y = SelectedItem }; } return true; @@ -675,178 +451,117 @@ public virtual bool MovePageUp () /// public virtual bool MoveUp () { - if (_source is null || _source.Count == 0) + if (Source is null || Source.Count == 0) { // Do we set lastSelectedItem to -1 here? return false; //Nothing for us to move to } - if (_selected >= _source.Count) + if (SelectedItem >= Source.Count) { - // If for some reason we are currently outside of the + // If for some reason we are currently outside the // valid values range, we should select the bottommost valid value. // This can occur if the backing data source changes. - _selected = _source.Count - 1; - OnSelectedChanged (); - SetNeedsDraw (); + SelectedItem = Source.Count - 1; } - else if (_selected > 0) + else if (SelectedItem > 0) { - _selected--; + SelectedItem--; - if (_selected > Source.Count) + if (SelectedItem > Source.Count) { - _selected = Source.Count - 1; + SelectedItem = Source.Count - 1; } - if (_selected < Viewport.Y) + if (SelectedItem < Viewport.Y) { - Viewport = Viewport with { Y = _selected }; + Viewport = Viewport with { Y = SelectedItem }; } - else if (_selected > Viewport.Y + Viewport.Height) + else if (SelectedItem > Viewport.Y + Viewport.Height) { - Viewport = Viewport with { Y = _selected - Viewport.Height + 1 }; + Viewport = Viewport with { Y = SelectedItem - Viewport.Height + 1 }; } - - OnSelectedChanged (); - SetNeedsDraw (); } - else if (_selected < Viewport.Y) + else if (SelectedItem < Viewport.Y) { - Viewport = Viewport with { Y = _selected }; - SetNeedsDraw (); + Viewport = Viewport with { Y = SelectedItem }; } return true; } - /// - protected override bool OnDrawingContent () - { - Attribute current = Attribute.Default; - Move (0, 0); - Rectangle f = Viewport; - int item = Viewport.Y; - bool focused = HasFocus; - int col = _allowsMarking ? 2 : 0; - int start = Viewport.X; - - for (var row = 0; row < f.Height; row++, item++) - { - bool isSelected = item == _selected; - - Attribute newAttribute = focused ? isSelected ? GetAttributeForRole (VisualRole.Focus) : GetAttributeForRole (VisualRole.Normal) : - isSelected ? GetAttributeForRole (VisualRole.Active) : GetAttributeForRole (VisualRole.Normal); - - if (newAttribute != current) - { - SetAttribute (newAttribute); - current = newAttribute; - } - - Move (0, row); - - if (_source is null || item >= _source.Count) - { - for (var c = 0; c < f.Width; c++) - { - AddRune ((Rune)' '); - } - } - else - { - var rowEventArgs = new ListViewRowEventArgs (item); - OnRowRender (rowEventArgs); - - if (rowEventArgs.RowAttribute is { } && current != rowEventArgs.RowAttribute) - { - current = (Attribute)rowEventArgs.RowAttribute; - SetAttribute (current); - } - - if (_allowsMarking) - { - AddRune ( - _source.IsMarked (item) ? AllowsMultipleSelection ? Glyphs.CheckStateChecked : Glyphs.Selected : - AllowsMultipleSelection ? Glyphs.CheckStateUnChecked : Glyphs.UnSelected - ); - AddRune ((Rune)' '); - } - - Source.Render (this, isSelected, item, col, row, f.Width - col, start); - } - } - return true; - } - - /// - protected override void OnHasFocusChanged (bool newHasFocus, [CanBeNull] View currentFocused, [CanBeNull] View newFocused) - { - if (newHasFocus && _lastSelectedItem != _selected) - { - EnsureSelectedItemVisible (); - } - } - /// Invokes the event if it is defined. /// if the event was fired. public bool OnOpenSelectedItem () { - if (_source is null || _source.Count <= _selected || _selected < 0 || OpenSelectedItem is null) + if (Source is null || Source.Count <= SelectedItem || SelectedItem < 0 || OpenSelectedItem is null) { return false; } - object value = _source.ToList () [_selected]; + object? value = Source.ToList () [SelectedItem]; - OpenSelectedItem?.Invoke (this, new ListViewItemEventArgs (_selected, value)); + OpenSelectedItem?.Invoke (this, new (SelectedItem, value)); // BUGBUG: this should not blindly return true. return true; } - /// - protected override bool OnKeyDown (Key key) + /// Virtual method that will invoke the . + /// + public virtual void OnRowRender (ListViewRowEventArgs rowEventArgs) { RowRender?.Invoke (this, rowEventArgs); } + + + /// This event is raised when the user Double-Clicks on an item or presses ENTER to open the selected item. + public event EventHandler? OpenSelectedItem; + + /// + /// Allow resume the event from being invoked, + /// + public void ResumeSuspendCollectionChangedEvent () { - // If the key was bound to key command, let normal KeyDown processing happen. This enables overriding the default handling. - // See: https://github.com/gui-cs/Terminal.Gui/issues/3950#issuecomment-2807350939 - if (KeyBindings.TryGet (key, out _)) + if (Source is { }) { - return false; + Source.SuspendCollectionChangedEvent = false; } + } - // Enable user to find & select an item by typing text - if (KeystrokeNavigator.Matcher.IsCompatibleKey (key)) - { - int? newItem = KeystrokeNavigator?.GetNextMatchingItem (SelectedItem, (char)key); + /// This event is invoked when this is being drawn before rendering. + public event EventHandler? RowRender; - if (newItem is { } && newItem != -1) + /// Gets or sets the index of the currently selected item. + /// The selected item. + public int SelectedItem + { + get => _selectedItem; + set + { + if (Source is null || Source.Count == 0) { - SelectedItem = (int)newItem; - EnsureSelectedItemVisible (); - SetNeedsDraw (); + return; + } - return true; + if (value < -1 || value >= Source.Count) + { + throw new ArgumentException ("value"); } - } - return false; + _selectedItem = value; + OnSelectedChanged (); + SetNeedsDraw (); + } } - /// Virtual method that will invoke the . - /// - public virtual void OnRowRender (ListViewRowEventArgs rowEventArgs) { RowRender?.Invoke (this, rowEventArgs); } - // TODO: Use standard event model /// Invokes the event if it is defined. /// public virtual bool OnSelectedChanged () { - if (_selected != _lastSelectedItem) + if (SelectedItem != _lastSelectedItem) { - object value = _source?.Count > 0 ? _source.ToList () [_selected] : null; - SelectedItemChanged?.Invoke (this, new ListViewItemEventArgs (_selected, value)); - _lastSelectedItem = _selected; + object? value = SelectedItem != -1 && Source?.Count > 0 ? Source.ToList () [SelectedItem] : null; + SelectedItemChanged?.Invoke (this, new (SelectedItem, value)); + _lastSelectedItem = SelectedItem; EnsureSelectedItemVisible (); return true; @@ -855,19 +570,8 @@ public virtual bool OnSelectedChanged () return false; } - /// This event is raised when the user Double-Clicks on an item or presses ENTER to open the selected item. - public event EventHandler OpenSelectedItem; - - /// This event is invoked when this is being drawn before rendering. - public event EventHandler RowRender; - /// This event is raised when the selected item in the has changed. - public event EventHandler SelectedItemChanged; - - /// - /// Event to raise when an item is added, removed, or moved, or the entire list is refreshed. - /// - public event NotifyCollectionChangedEventHandler CollectionChanged; + public event EventHandler? SelectedItemChanged; /// Sets the source of the to an . /// An object implementing the IList interface. @@ -875,7 +579,7 @@ public virtual bool OnSelectedChanged () /// Use the property to set a new source and use custom /// rendering. /// - public void SetSource (ObservableCollection source) + public void SetSource (ObservableCollection? source) { if (source is null && Source is not ListWrapper) { @@ -893,12 +597,12 @@ public void SetSource (ObservableCollection source) /// Use the property to set a new source and use custom /// rendering. /// - public Task SetSourceAsync (ObservableCollection source) + public Task SetSourceAsync (ObservableCollection? source) { return Task.Factory.StartNew ( () => { - if (source is null && (Source is null || !(Source is ListWrapper))) + if (source is null && Source is not ListWrapper) { Source = null; } @@ -915,23 +619,37 @@ public Task SetSourceAsync (ObservableCollection source) ); } - private void ListView_LayoutStarted (object sender, LayoutEventArgs e) { EnsureSelectedItemVisible (); } - /// - /// Call the event to raises the . - /// - /// - protected virtual void OnCollectionChanged (NotifyCollectionChangedEventArgs e) { CollectionChanged?.Invoke (this, e); } - - /// - protected override void Dispose (bool disposing) + /// Gets or sets the backing this , enabling custom rendering. + /// The source. + /// Use to set a new source. + public IListDataSource? Source { - _source?.Dispose (); + get => _source; + set + { + if (_source == value) + { + return; + } - base.Dispose (disposing); + _source?.Dispose (); + _source = value; + + if (_source is { }) + { + _source.CollectionChanged += Source_CollectionChanged; + SetContentSize (new Size (_source?.Length ?? Viewport.Width, _source?.Count ?? Viewport.Width)); + KeystrokeNavigator.Collection = _source?.ToList (); + } + + SelectedItem = -1; + _lastSelectedItem = -1; + SetNeedsDraw (); + } } /// - /// Allow suspending the event from being invoked, + /// Allow suspending the event from being invoked, /// public void SuspendCollectionChangedEvent () { @@ -941,245 +659,267 @@ public void SuspendCollectionChangedEvent () } } - /// - /// Allow resume the event from being invoked, - /// - public void ResumeSuspendCollectionChangedEvent () + /// Gets or sets the index of the item that will appear at the top of the . + /// + /// This a helper property for accessing listView.Viewport.Y. + /// + /// The top item. + public int TopItem { - if (Source is { }) + get => Viewport.Y; + set { - Source.SuspendCollectionChangedEvent = false; - } - } - - /// - public bool EnableForDesign () - { - var source = new ListWrapper (["List Item 1", "List Item two", "List Item Quattro", "Last List Item"]); - Source = source; + if (Source is null) + { + return; + } - return true; + Viewport = Viewport with { Y = value }; + } } -} - -/// -/// Provides a default implementation of that renders items -/// using . -/// -public class ListWrapper : IListDataSource, IDisposable -{ - private int _count; - private BitArray _marks; - private readonly ObservableCollection _source; - /// - public ListWrapper (ObservableCollection source) + /// + /// If and are both , + /// unmarks all marked items other than . + /// + /// if unmarking was successful. + public bool UnmarkAllButSelected () { - if (source is { }) + if (!_allowsMarking) { - _count = source.Count; - _marks = new BitArray (_count); - _source = source; - _source.CollectionChanged += Source_CollectionChanged; - Length = GetMaxLengthItem (); + return false; } - } - private void Source_CollectionChanged (object sender, NotifyCollectionChangedEventArgs e) - { - if (!SuspendCollectionChangedEvent) + if (!AllowsMultipleSelection) { - CheckAndResizeMarksIfRequired (); - CollectionChanged?.Invoke (sender, e); + for (var i = 0; i < Source?.Count; i++) + { + if (Source.IsMarked (i) && i != SelectedItem) + { + Source.SetMark (i, false); + + return true; + } + } } - } - /// - public event NotifyCollectionChangedEventHandler CollectionChanged; + return true; + } /// - public int Count => _source?.Count ?? 0; + protected override void Dispose (bool disposing) + { + Source?.Dispose (); - /// - public int Length { get; private set; } + base.Dispose (disposing); + } - private bool _suspendCollectionChangedEvent; + /// + /// Call the event to raises the . + /// + /// + protected virtual void OnCollectionChanged (NotifyCollectionChangedEventArgs e) { CollectionChanged?.Invoke (this, e); } - /// - public bool SuspendCollectionChangedEvent + /// + protected override bool OnDrawingContent () { - get => _suspendCollectionChangedEvent; - set + if (Source is null) { - _suspendCollectionChangedEvent = value; - - if (!_suspendCollectionChangedEvent) - { - CheckAndResizeMarksIfRequired (); - } + return base.OnDrawingContent (); } - } - private void CheckAndResizeMarksIfRequired () - { - if (_source != null && _count != _source.Count) + var current = Attribute.Default; + Move (0, 0); + Rectangle f = Viewport; + int item = Viewport.Y; + bool focused = HasFocus; + int col = _allowsMarking ? 2 : 0; + int start = Viewport.X; + + for (var row = 0; row < f.Height; row++, item++) { - _count = _source.Count; - BitArray newMarks = new BitArray (_count); - for (var i = 0; i < Math.Min (_marks.Length, newMarks.Length); i++) - { - newMarks [i] = _marks [i]; - } - _marks = newMarks; + bool isSelected = item == SelectedItem; - Length = GetMaxLengthItem (); - } - } + Attribute newAttribute = focused ? isSelected ? GetAttributeForRole (VisualRole.Focus) : GetAttributeForRole (VisualRole.Normal) : + isSelected ? GetAttributeForRole (VisualRole.Active) : GetAttributeForRole (VisualRole.Normal); - /// - public void Render ( - ListView container, - bool marked, - int item, - int col, - int line, - int width, - int viewportXOffset = 0 - ) - { - container.Move (Math.Max (col - viewportXOffset, 0), line); + if (newAttribute != current) + { + SetAttribute (newAttribute); + current = newAttribute; + } - if (_source is { }) - { - object t = _source [item]; + Move (0, row); - if (t is null) + if (Source is null || item >= Source.Count) { - RenderUstr (container, "", col, line, width); + for (var c = 0; c < f.Width; c++) + { + AddRune ((Rune)' '); + } } else { - if (t is string s) + var rowEventArgs = new ListViewRowEventArgs (item); + OnRowRender (rowEventArgs); + + if (rowEventArgs.RowAttribute is { } && current != rowEventArgs.RowAttribute) { - RenderUstr (container, s, col, line, width, viewportXOffset); + current = (Attribute)rowEventArgs.RowAttribute; + SetAttribute (current); } - else + + if (_allowsMarking) { - RenderUstr (container, t.ToString (), col, line, width, viewportXOffset); + AddRune ( + Source.IsMarked (item) ? AllowsMultipleSelection ? Glyphs.CheckStateChecked : Glyphs.Selected : + AllowsMultipleSelection ? Glyphs.CheckStateUnChecked : Glyphs.UnSelected + ); + AddRune ((Rune)' '); } + + Source.Render (this, isSelected, item, col, row, f.Width - col, start); } } + + return true; } /// - public bool IsMarked (int item) - { - if (item >= 0 && item < _count) - { - return _marks [item]; - } - - return false; - } + protected override void OnFrameChanged (in Rectangle frame) { EnsureSelectedItemVisible (); } /// - public void SetMark (int item, bool value) + protected override void OnHasFocusChanged (bool newHasFocus, View? currentFocused, View? newFocused) { - if (item >= 0 && item < _count) + if (newHasFocus && _lastSelectedItem != SelectedItem) { - _marks [item] = value; + EnsureSelectedItemVisible (); } } /// - public IList ToList () { return _source; } - - /// - public int StartsWith (string search) + protected override bool OnKeyDown (Key key) { - if (_source is null || _source?.Count == 0) + // If the key was bound to key command, let normal KeyDown processing happen. This enables overriding the default handling. + // See: https://github.com/gui-cs/Terminal.Gui/issues/3950#issuecomment-2807350939 + if (KeyBindings.TryGet (key, out _)) { - return -1; + return false; } - for (var i = 0; i < _source.Count; i++) + // Enable user to find & select an item by typing text + if (KeystrokeNavigator.Matcher.IsCompatibleKey (key)) { - object t = _source [i]; + int? newItem = KeystrokeNavigator?.GetNextMatchingItem (SelectedItem, (char)key); - if (t is string u) - { - if (u.ToUpper ().StartsWith (search.ToUpperInvariant ())) - { - return i; - } - } - else if (t is string s) + if (newItem is { } && newItem != -1) { - if (s.StartsWith (search, StringComparison.InvariantCultureIgnoreCase)) - { - return i; - } + SelectedItem = (int)newItem; + EnsureSelectedItemVisible (); + SetNeedsDraw (); + + return true; } } - return -1; + return false; } - private int GetMaxLengthItem () + /// + protected override bool OnMouseEvent (MouseEventArgs me) { - if (_source is null || _source?.Count == 0) + if (!me.Flags.HasFlag (MouseFlags.Button1Clicked) + && !me.Flags.HasFlag (MouseFlags.Button1DoubleClicked) + && me.Flags != MouseFlags.WheeledDown + && me.Flags != MouseFlags.WheeledUp + && me.Flags != MouseFlags.WheeledRight + && me.Flags != MouseFlags.WheeledLeft) { - return 0; + return false; } - var maxLength = 0; + if (!HasFocus && CanFocus) + { + SetFocus (); + } - for (var i = 0; i < _source!.Count; i++) + if (Source is null) { - object t = _source [i]; - int l; + return false; + } - if (t is string u) - { - l = u.GetColumns (); - } - else if (t is string s) - { - l = s.Length; - } - else + if (me.Flags == MouseFlags.WheeledDown) + { + if (Viewport.Y + Viewport.Height < GetContentSize ().Height) { - l = t.ToString ().Length; + ScrollVertical (1); } - if (l > maxLength) + return true; + } + + if (me.Flags == MouseFlags.WheeledUp) + { + ScrollVertical (-1); + + return true; + } + + if (me.Flags == MouseFlags.WheeledRight) + { + if (Viewport.X + Viewport.Width < GetContentSize ().Width) { - maxLength = l; + ScrollHorizontal (1); } + + return true; } - return maxLength; - } + if (me.Flags == MouseFlags.WheeledLeft) + { + ScrollHorizontal (-1); - private void RenderUstr (View driver, string ustr, int col, int line, int width, int viewportXOffset = 0) - { - string str = viewportXOffset > ustr.GetColumns () ? string.Empty : ustr.Substring (Math.Min (viewportXOffset, ustr.ToRunes ().Length - 1)); - string u = TextFormatter.ClipAndJustify (str, width, Alignment.Start); - driver.AddStr (u); - width -= u.GetColumns (); + return true; + } - while (width-- > 0) + if (me.Position.Y + Viewport.Y >= Source.Count + || me.Position.Y + Viewport.Y < 0 + || me.Position.Y + Viewport.Y > Viewport.Y + Viewport.Height) { - driver.AddRune ((Rune)' '); + return true; } + + SelectedItem = Viewport.Y + me.Position.Y; + + if (MarkUnmarkSelectedItem ()) + { + // return true; + } + + SetNeedsDraw (); + + if (me.Flags == MouseFlags.Button1DoubleClicked) + { + return InvokeCommand (Command.Accept) is true; + } + + return true; } - /// - public void Dispose () + /// + protected override void OnViewportChanged (DrawEventArgs e) { SetContentSize (new Size (MaxLength, Source?.Count ?? Viewport.Height)); } + + private void Source_CollectionChanged (object? sender, NotifyCollectionChangedEventArgs e) { - if (_source is { }) + SetContentSize (new Size (Source?.Length ?? Viewport.Width, Source?.Count ?? Viewport.Width)); + + if (Source is { Count: > 0 } && SelectedItem > Source.Count - 1) { - _source.CollectionChanged -= Source_CollectionChanged; + SelectedItem = Source.Count - 1; } + + SetNeedsDraw (); + + OnCollectionChanged (e); } } diff --git a/Terminal.Gui/Views/ListWrapper.cs b/Terminal.Gui/Views/ListWrapper.cs new file mode 100644 index 0000000000..1b2a8ae742 --- /dev/null +++ b/Terminal.Gui/Views/ListWrapper.cs @@ -0,0 +1,266 @@ +#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) + { + if (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; + + if (t is string u) + { + l = u.GetColumns (); + } + else + { + l = 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.sln.DotSettings b/Terminal.sln.DotSettings index 4161569b11..ef25662a47 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -421,6 +421,7 @@ True True True + True True True True diff --git a/Tests/UnitTests/Views/ListViewTests.cs b/Tests/UnitTests/Views/ListViewTests.cs index 1d58198bc9..414772c47b 100644 --- a/Tests/UnitTests/Views/ListViewTests.cs +++ b/Tests/UnitTests/Views/ListViewTests.cs @@ -756,7 +756,7 @@ public void Render ( int col, int line, int width, - int viewportXOffset = 0 + int viewportX = 0 ) { throw new NotImplementedException (); diff --git a/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs b/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs new file mode 100644 index 0000000000..3c3850fa8f --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs @@ -0,0 +1,494 @@ +ο»Ώ#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 += (_, _) => 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 += (_, 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 += (_, _) => 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); + } + #endregion +} From cf613a204d46ce36b6498045310641851ac93322 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 15 Nov 2025 12:57:38 -0700 Subject: [PATCH 05/17] 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. --- Terminal.Gui/Views/ComboBox.cs | 4 +- Terminal.Gui/Views/ListView.cs | 2 +- Tests/UnitTests/Views/ListViewTests.cs | 991 +++--------------- .../Views/IListDataSourceTests.cs | 35 +- .../Views/ListViewTests.cs | 812 +++++++++++++- 5 files changed, 933 insertions(+), 911 deletions(-) diff --git a/Terminal.Gui/Views/ComboBox.cs b/Terminal.Gui/Views/ComboBox.cs index 6da8acaeac..ed87a93b41 100644 --- a/Terminal.Gui/Views/ComboBox.cs +++ b/Terminal.Gui/Views/ComboBox.cs @@ -46,9 +46,9 @@ public ComboBox () }; _listview.SelectedItemChanged += (sender, e) => { - if (!HideDropdownListOnClick && _searchSet.Count > 0) + if (e.Item >= 0 && !HideDropdownListOnClick && _searchSet.Count > 0) { - SetValue (_searchSet [_listview.SelectedItem]); + SetValue (_searchSet [e.Item]); } }; Add (_search, _listview); diff --git a/Terminal.Gui/Views/ListView.cs b/Terminal.Gui/Views/ListView.cs index 41953434d4..9a464dc22b 100644 --- a/Terminal.Gui/Views/ListView.cs +++ b/Terminal.Gui/Views/ListView.cs @@ -559,7 +559,7 @@ public virtual bool OnSelectedChanged () { if (SelectedItem != _lastSelectedItem) { - object? value = SelectedItem != -1 && Source?.Count > 0 ? Source.ToList () [SelectedItem] : null; + object? value = SelectedItem >= 0 && Source?.Count > 0 ? Source.ToList () [SelectedItem] : null; SelectedItemChanged?.Invoke (this, new (SelectedItem, value)); _lastSelectedItem = SelectedItem; EnsureSelectedItemVisible (); diff --git a/Tests/UnitTests/Views/ListViewTests.cs b/Tests/UnitTests/Views/ListViewTests.cs index 414772c47b..c4aca02012 100644 --- a/Tests/UnitTests/Views/ListViewTests.cs +++ b/Tests/UnitTests/Views/ListViewTests.cs @@ -1,8 +1,4 @@ -ο»Ώusing System.Collections; -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using Moq; -using UnitTests; +ο»Ώusing System.Collections.ObjectModel; using Xunit.Abstractions; namespace UnitTests.ViewsTests; @@ -10,35 +6,73 @@ namespace UnitTests.ViewsTests; public class ListViewTests (ITestOutputHelper output) { [Fact] - public void Constructors_Defaults () + [AutoInitShutdown] + public void Clicking_On_Border_Is_Ignored () { - 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); + var selected = ""; - lv = new () + var lv = new ListView { - Y = 1, Width = 10, Height = 20, Source = new ListWrapper (["One", "Two", "Three"]) + Height = 5, + Width = 7, + BorderStyle = LineStyle.Single }; - Assert.NotNull (lv.Source); + 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 (new (0, 1, 10, 20), lv.Frame); + Assert.Equal ("", lv.Text); - lv = new () { Y = 1, Width = 10, Height = 20, Source = new NewListDataSource () }; - Assert.NotNull (lv.Source); + DriverAssert.AssertDriverContentsWithFrameAre ( + @" +β”Œβ”€β”€β”€β”€β”€β” +β”‚One β”‚ +β”‚Two β”‚ +β”‚Threeβ”‚ +β””β”€β”€β”€β”€β”€β”˜", + output); + + Application.RaiseMouseEvent (new () { ScreenPosition = new (0, 0), Flags = MouseFlags.Button1Clicked }); + Assert.Equal ("", selected); Assert.Equal (-1, lv.SelectedItem); - Assert.Equal (new (0, 1, 10, 20), lv.Frame); + 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] @@ -64,7 +98,7 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () Assert.Equal (-1, lv.SelectedItem); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚Line0 β”‚ β”‚Line1 β”‚ @@ -77,15 +111,15 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () β”‚Line8 β”‚ β”‚Line9 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", - output - ); + output + ); Assert.True (lv.ScrollVertical (10)); AutoInitShutdownAttribute.RunIteration (); Assert.Equal (-1, lv.SelectedItem); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚Line10 β”‚ β”‚Line11 β”‚ @@ -98,15 +132,15 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () β”‚Line18 β”‚ β”‚Line19 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", - output - ); + output + ); Assert.True (lv.MoveDown ()); AutoInitShutdownAttribute.RunIteration (); Assert.Equal (0, lv.SelectedItem); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚Line0 β”‚ β”‚Line1 β”‚ @@ -119,15 +153,15 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () β”‚Line8 β”‚ β”‚Line9 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", - output - ); + output + ); Assert.True (lv.MoveEnd ()); AutoInitShutdownAttribute.RunIteration (); Assert.Equal (19, lv.SelectedItem); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚Line10 β”‚ β”‚Line11 β”‚ @@ -140,15 +174,15 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () β”‚Line18 β”‚ β”‚Line19 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", - output - ); + output + ); Assert.True (lv.ScrollVertical (-20)); AutoInitShutdownAttribute.RunIteration (); Assert.Equal (19, lv.SelectedItem); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚Line0 β”‚ β”‚Line1 β”‚ @@ -161,15 +195,15 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () β”‚Line8 β”‚ β”‚Line9 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", - output - ); + output + ); Assert.True (lv.MoveDown ()); AutoInitShutdownAttribute.RunIteration (); Assert.Equal (19, lv.SelectedItem); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚Line10 β”‚ β”‚Line11 β”‚ @@ -182,15 +216,15 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () β”‚Line18 β”‚ β”‚Line19 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", - output - ); + output + ); Assert.True (lv.ScrollVertical (-20)); AutoInitShutdownAttribute.RunIteration (); Assert.Equal (19, lv.SelectedItem); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚Line0 β”‚ β”‚Line1 β”‚ @@ -203,15 +237,15 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () β”‚Line8 β”‚ β”‚Line9 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", - output - ); + output + ); Assert.True (lv.MoveDown ()); AutoInitShutdownAttribute.RunIteration (); Assert.Equal (19, lv.SelectedItem); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚Line10 β”‚ β”‚Line11 β”‚ @@ -224,15 +258,15 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () β”‚Line18 β”‚ β”‚Line19 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", - output - ); + output + ); Assert.True (lv.MoveHome ()); AutoInitShutdownAttribute.RunIteration (); Assert.Equal (0, lv.SelectedItem); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚Line0 β”‚ β”‚Line1 β”‚ @@ -245,15 +279,15 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () β”‚Line8 β”‚ β”‚Line9 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", - output - ); + output + ); Assert.True (lv.ScrollVertical (20)); AutoInitShutdownAttribute.RunIteration (); Assert.Equal (0, lv.SelectedItem); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚Line19 β”‚ β”‚ β”‚ @@ -266,15 +300,15 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", - output - ); + output + ); Assert.True (lv.MoveUp ()); AutoInitShutdownAttribute.RunIteration (); Assert.Equal (0, lv.SelectedItem); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚Line0 β”‚ β”‚Line1 β”‚ @@ -287,8 +321,8 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () β”‚Line8 β”‚ β”‚Line9 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", - output - ); + output + ); top.Dispose (); } @@ -310,28 +344,28 @@ public void EnsureSelectedItemVisible_SelectedItem () AutoInitShutdownAttribute.RunIteration (); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" Item 0 Item 1 Item 2 Item 3 Item 4", - output - ); + 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 - ); + output + ); top.Dispose (); } @@ -367,477 +401,7 @@ string GetContents (int line) 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 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] - [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 (); } @@ -847,7 +411,7 @@ public void LeftItem_TopItem_Tests () { ObservableCollection source = []; - for (int i = 0; i < 5; i++) + for (var i = 0; i < 5; i++) { source.Add ($"Item {i}"); } @@ -865,361 +429,44 @@ public void LeftItem_TopItem_Tests () AutoInitShutdownAttribute.RunIteration (); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" Item 0 Item 1 Item 2 Item 3 Item 4", - output); + output); lv.LeftItem = 1; lv.TopItem = 1; AutoInitShutdownAttribute.RunIteration (); DriverAssert.AssertDriverContentsWithFrameAre ( - @" + @" tem 1 tem 2 tem 3 tem 4", - output); + 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 () + [AutoInitShutdown] + public void RowRender_Event () { - 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); - + 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); - void Lw_CollectionChanged (object sender, NotifyCollectionChangedEventArgs e) - { - if (e.Action == NotifyCollectionChangedAction.Add) - { - added++; - } - } + lv.SetSource (source); + lv.Draw (); + Assert.True (rendered); + top.Dispose (); } -} \ No newline at end of file +} diff --git a/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs b/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs index 3c3850fa8f..10f9498f11 100644 --- a/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs +++ b/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs @@ -4,6 +4,7 @@ using System.Collections.Specialized; using System.Text; using Xunit.Abstractions; + // ReSharper disable InconsistentNaming namespace UnitTests_Parallelizable.ViewTests; @@ -21,7 +22,7 @@ public void ListWrapper_SuspendAndModify_NoEventsUntilResume () ListWrapper wrapper = new (source); var eventCount = 0; - wrapper.CollectionChanged += (_, _) => eventCount++; + wrapper.CollectionChanged += (s, e) => eventCount++; wrapper.SuspendCollectionChangedEvent = true; @@ -136,7 +137,7 @@ public void AddItem (string item) [Fact] public void ListWrapper_Render_NullItem_RendersEmpty () { - ObservableCollection source = [null!, "Item2"]; + ObservableCollection source = [null, "Item2"]; ListWrapper wrapper = new (source); var listView = new ListView { Width = 20, Height = 2 }; listView.BeginInit (); @@ -296,7 +297,7 @@ public void CustomDataSource_AllMembers_WorkCorrectly () // Test render doesn't throw listView.BeginInit (); listView.EndInit (); - Exception? ex = Record.Exception (() => customSource.Render (listView, false, 0, 0, 0, 20)); + Exception ex = Record.Exception (() => customSource.Render (listView, false, 0, 0, 0, 20)); Assert.Null (ex); } @@ -307,7 +308,7 @@ public void CustomDataSource_CollectionChanged_RaisedOnModification () var eventRaised = false; NotifyCollectionChangedAction? action = null; - customSource.CollectionChanged += (_, e) => + customSource.CollectionChanged += (s, e) => { eventRaised = true; action = e.Action; @@ -326,7 +327,7 @@ public void CustomDataSource_SuspendCollectionChanged_SuppressesEvents () var customSource = new TestListDataSource (); var eventCount = 0; - customSource.CollectionChanged += (_, _) => eventCount++; + customSource.CollectionChanged += (s, e) => eventCount++; customSource.SuspendCollectionChangedEvent = true; customSource.AddItem ("Item 1"); @@ -342,6 +343,9 @@ public void CustomDataSource_SuspendCollectionChanged_SuppressesEvents () public void CustomDataSource_Dispose_CleansUp () { var customSource = new TestListDataSource (); + var eventRaised = false; + + customSource.CollectionChanged += (s, e) => eventRaised = true; customSource.Dispose (); @@ -396,7 +400,7 @@ public void ListWrapper_SetMark_OutOfBounds_DoesNotThrow () ObservableCollection source = ["Item1"]; ListWrapper wrapper = new (source); - Exception? ex = Record.Exception (() => wrapper.SetMark (-1, true)); + Exception ex = Record.Exception (() => wrapper.SetMark (-1, true)); Assert.Null (ex); ex = Record.Exception (() => wrapper.SetMark (100, true)); @@ -490,5 +494,24 @@ public void ListWrapper_MaxLength_UpdatesOnCollectionChange () source.Add ("X"); Assert.Equal (1, wrapper.Length); } + + [Fact] + public void ListWrapper_Dispose_UnsubscribesFromCollectionChanged () + { + ObservableCollection source = ["Item1"]; + ListWrapper wrapper = new (source); + var eventRaised = false; + + wrapper.CollectionChanged += (s, e) => eventRaised = true; + + wrapper.Dispose (); + + // After dispose, source changes should not raise wrapper events + source.Add ("Item2"); + + // The wrapper's event might still fire, but the wrapper won't propagate source events + // This depends on implementation + } + #endregion } diff --git a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs index 9b942d6810..a068362b9c 100644 --- a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs @@ -1,15 +1,65 @@ -ο»Ώusing System.Collections.ObjectModel; +ο»Ώusing System.Collections; +using System.Collections.ObjectModel; +using System.Collections.Specialized; using Moq; namespace UnitTests_Parallelizable.ViewsTests; public class ListViewTests { + [Fact] + public void CollectionNavigatorMatcher_KeybindingsOverrideNavigator () + { + ObservableCollection source = new () { "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.Equal (-1, 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 = new () { "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.Equal (-1, 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) }; + var lv = new ListView { Source = new ListWrapper (source) }; // Keys are consumed during navigation Assert.True (lv.NewKeyDownEvent (Key.B)); @@ -23,10 +73,9 @@ public void ListViewCollectionNavigatorMatcher_DefaultBehaviour () public void ListViewCollectionNavigatorMatcher_IgnoreKeys () { ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - ListView lv = new ListView { Source = new ListWrapper (source) }; + var lv = new ListView { Source = new ListWrapper (source) }; - - var matchNone = new Mock (); + Mock matchNone = new (); matchNone.Setup (m => m.IsCompatibleKey (It.IsAny ())) .Returns (false); @@ -46,10 +95,9 @@ public void ListViewCollectionNavigatorMatcher_IgnoreKeys () public void ListViewCollectionNavigatorMatcher_OverrideMatching () { ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - ListView lv = new ListView { Source = new ListWrapper (source) }; - + var lv = new ListView { Source = new ListWrapper (source) }; - var matchNone = new Mock (); + Mock matchNone = new (); matchNone.Setup (m => m.IsCompatibleKey (It.IsAny ())) .Returns (true); @@ -59,6 +107,7 @@ public void ListViewCollectionNavigatorMatcher_OverrideMatching () .Returns ((string s, object key) => s.StartsWith ('B') && key?.ToString () == "candle"); lv.KeystrokeNavigator.Matcher = matchNone.Object; + // Keys are consumed during navigation Assert.True (lv.NewKeyDownEvent (Key.B)); Assert.Equal (5, lv.SelectedItem); @@ -70,51 +119,754 @@ public void ListViewCollectionNavigatorMatcher_OverrideMatching () Assert.Equal ("candle", (string)lv.Source.ToList () [lv.SelectedItem]); } + #region ListView Tests (from ListViewTests.cs - parallelizable) + [Fact] - public void ListView_CollectionNavigatorMatcher_KeybindingsOverrideNavigator () + public void Constructors_Defaults () { - ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - ListView lv = new ListView { Source = new ListWrapper (source) }; + var lv = new ListView (); + Assert.Null (lv.Source); + Assert.True (lv.CanFocus); + Assert.Equal (-1, lv.SelectedItem); + Assert.False (lv.AllowsMultipleSelection); - lv.SetFocus (); + lv = new () { Source = new ListWrapper (["One", "Two", "Three"]) }; + Assert.NotNull (lv.Source); + Assert.Equal (-1, lv.SelectedItem); - lv.KeyBindings.Add (Key.B, Command.Down); + 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); - // Keys should be consumed to move down the navigation i.e. to apricot - Assert.True (lv.NewKeyDownEvent (Key.B)); + lv = new () { Y = 1, Width = 10, Height = 20, Source = new NewListDataSource () }; + Assert.NotNull (lv.Source); + Assert.Equal (-1, lv.SelectedItem); + Assert.Equal (new (0, 1, 10, 20), lv.Frame); + } + + private class NewListDataSource : IListDataSource + { +#pragma warning disable CS0067 + public event NotifyCollectionChangedEventHandler? CollectionChanged; +#pragma warning restore CS0067 + + public int Count => 0; + public int Length => 0; + + public bool SuspendCollectionChangedEvent + { + get => throw new NotImplementedException (); + set => throw new NotImplementedException (); + } + + public bool IsMarked (int item) { throw new NotImplementedException (); } + + public void Render ( + ListView container, + bool selected, + int item, + int col, + int line, + int width, + int viewportX = 0 + ) + { + throw new NotImplementedException (); + } + + public void SetMark (int item, bool value) { throw new NotImplementedException (); } + public IList ToList () { return new List { "One", "Two", "Three" }; } + + public void Dispose () { throw new NotImplementedException (); } + } + + [Fact] + public void KeyBindings_Command () + { + ObservableCollection source = ["One", "Two", "Three"]; + var lv = new ListView { Height = 2, AllowsMarking = true, Source = new ListWrapper (source) }; + lv.BeginInit (); + lv.EndInit (); + Assert.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); + } - Assert.True (lv.NewKeyDownEvent (Key.B)); + [Fact] + public void HotKey_Command_SetsFocus () + { + var view = new ListView (); + + view.CanFocus = true; + Assert.False (view.HasFocus); + view.InvokeCommand (Command.HotKey); + Assert.True (view.HasFocus); + } + + [Fact] + public void HotKey_Command_Does_Not_Accept () + { + var listView = new ListView (); + var accepted = false; + + listView.Accepting += OnAccepted; + listView.InvokeCommand (Command.HotKey); + + Assert.False (accepted); + + return; + + void OnAccepted (object sender, CommandEventArgs e) { accepted = true; } + } + + [Fact] + public void Accept_Command_Accepts_and_Opens_Selected_Item () + { + ObservableCollection source = ["One", "Two", "Three"]; + var listView = new ListView { Source = new ListWrapper (source) }; + listView.SelectedItem = 0; + + var accepted = false; + var opened = false; + var selectedValue = string.Empty; + + listView.Accepting += Accepted; + listView.OpenSelectedItem += OpenSelectedItem; + + listView.InvokeCommand (Command.Accept); + + Assert.True (accepted); + Assert.True (opened); + Assert.Equal (source [0], selectedValue); + + return; + + void OpenSelectedItem (object sender, ListViewItemEventArgs e) + { + opened = true; + selectedValue = e.Value.ToString (); + } + + void Accepted (object sender, CommandEventArgs e) { accepted = true; } + } + + [Fact] + public void Accept_Cancel_Event_Prevents_OpenSelectedItem () + { + ObservableCollection source = ["One", "Two", "Three"]; + var listView = new ListView { Source = new ListWrapper (source) }; + listView.SelectedItem = 0; + + var accepted = false; + var opened = false; + var selectedValue = string.Empty; + + listView.Accepting += Accepted; + listView.OpenSelectedItem += OpenSelectedItem; + + listView.InvokeCommand (Command.Accept); + + Assert.True (accepted); + Assert.False (opened); + Assert.Equal (string.Empty, selectedValue); + + return; + + void OpenSelectedItem (object sender, ListViewItemEventArgs e) + { + opened = true; + selectedValue = e.Value.ToString (); + } + + void Accepted (object sender, CommandEventArgs e) + { + accepted = true; + e.Handled = true; + } + } + + [Fact] + public void ListViewProcessKeyReturnValue_WithMultipleCommands () + { + var lv = new ListView { Source = new ListWrapper (["One", "Two", "Three", "Four"]) }; + + Assert.NotNull (lv.Source); + + // first item should be deselected by default + Assert.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); - // There is no keybinding for Key.C so it hits collection navigator i.e. we jump to candle - Assert.True (lv.NewKeyDownEvent (Key.C)); - Assert.Equal (5, lv.SelectedItem); + // clear the items + lv.SetSource (null); + + // Press key combo again - return should be false this time as none of the Commands are allowable + Assert.False (lv.NewKeyDownEvent (ev), "We cannot move down so will not respond to this"); } [Fact] - public void CollectionNavigatorMatcher_KeybindingsOverrideNavigator () + public void AllowsMarking_True_SpaceWithShift_SelectsThenDown_SingleSelection () { - ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - ListView lv = new ListView { Source = new ListWrapper (source) }; + var lv = new ListView { Source = new ListWrapper (["One", "Two", "Three"]) }; + lv.AllowsMarking = true; + lv.AllowsMultipleSelection = false; - lv.SetFocus (); + Assert.NotNull (lv.Source); - lv.KeyBindings.Add (Key.B, Command.Down); + // first item should be deselected by default + Assert.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); - // Keys should be consumed to move down the navigation i.e. to apricot - Assert.True (lv.NewKeyDownEvent (Key.B)); + // 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); - Assert.True (lv.NewKeyDownEvent (Key.B)); + // none of the items should be ticked + Assert.False (lv.Source.IsMarked (0)); + Assert.False (lv.Source.IsMarked (1)); + Assert.False (lv.Source.IsMarked (2)); + + // Press key combo again + Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); + + // second item should now be selected Assert.Equal (1, lv.SelectedItem); - // There is no keybinding for Key.C so it hits collection navigator i.e. we jump to candle - Assert.True (lv.NewKeyDownEvent (Key.C)); - Assert.Equal (5, lv.SelectedItem); + // first item only should be ticked + Assert.True (lv.Source.IsMarked (0)); + Assert.False (lv.Source.IsMarked (1)); + Assert.False (lv.Source.IsMarked (2)); + + // Press key combo again + Assert.True (lv.NewKeyDownEvent (Key.Space.WithShift)); + Assert.Equal (2, lv.SelectedItem); + Assert.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.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)); + } + + [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 } From 062290048a4f5750d374acb5bcc3433cb051206f Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 15 Nov 2025 13:49:19 -0700 Subject: [PATCH 06/17] 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`. --- Tests/UnitTests/View/ArrangementTests.cs | 212 ++++++++ Tests/UnitTests/Views/ShortcutTests.cs | 38 ++ .../Configuration/SourcesManagerTests.cs | 22 +- .../View/ArrangementTests.cs | 454 +++++------------- .../View/Draw/NeedsDrawTests.cs | 14 +- .../View/Layout/Dim.AutoTests.PosTypes.cs | 2 +- .../View/Layout/Dim.AutoTests.cs | 14 +- .../View/Layout/FrameTests.cs | 10 +- .../View/SubviewTests.cs | 12 +- .../Views/NumericUpDownTests.cs | 10 +- .../Views/ShortcutTests.cs | 42 +- 11 files changed, 421 insertions(+), 409 deletions(-) create mode 100644 Tests/UnitTests/View/ArrangementTests.cs diff --git a/Tests/UnitTests/View/ArrangementTests.cs b/Tests/UnitTests/View/ArrangementTests.cs new file mode 100644 index 0000000000..1a912606b1 --- /dev/null +++ b/Tests/UnitTests/View/ArrangementTests.cs @@ -0,0 +1,212 @@ +ο»Ώusing Xunit.Abstractions; + +namespace UnitTests.ViewTests; + +public class ArrangementTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + [Fact] + public void MouseGrabHandler_WorksWithMovableView_UsingNewMouseEvent () + { + // This test proves that MouseGrabHandler works correctly with concurrent unit tests + // using NewMouseEvent directly on views, without requiring Application.Init + + var superView = new View + { + Width = 80, + Height = 25 + }; + + var movableView = new View + { + Arrangement = ViewArrangement.Movable, + BorderStyle = LineStyle.Single, + X = 10, + Y = 10, + Width = 20, + Height = 10 + }; + + superView.Add (movableView); + + // Verify initial state + Assert.NotNull (movableView.Border); + Assert.Null (Application.Mouse.MouseGrabView); + + // Simulate mouse press on the border to start dragging + var pressEvent = new MouseEventArgs + { + Position = new (1, 0), // Top border area + Flags = MouseFlags.Button1Pressed + }; + + bool? result = movableView.Border.NewMouseEvent (pressEvent); + + // The border should have grabbed the mouse + Assert.True (result); + Assert.Equal (movableView.Border, Application.Mouse.MouseGrabView); + + // Simulate mouse drag + var dragEvent = new MouseEventArgs + { + Position = new (5, 2), + Flags = MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition + }; + + result = movableView.Border.NewMouseEvent (dragEvent); + Assert.True (result); + + // Mouse should still be grabbed + Assert.Equal (movableView.Border, Application.Mouse.MouseGrabView); + + // Simulate mouse release to end dragging + var releaseEvent = new MouseEventArgs + { + Position = new (5, 2), + Flags = MouseFlags.Button1Released + }; + + result = movableView.Border.NewMouseEvent (releaseEvent); + Assert.True (result); + + // Mouse should be released + Assert.Null (Application.Mouse.MouseGrabView); + } + + [Fact] + public void MouseGrabHandler_WorksWithResizableView_UsingNewMouseEvent () + { + // This test proves MouseGrabHandler works for resizing operations + + var superView = new View + { + Width = 80, + Height = 25 + }; + + var resizableView = new View + { + Arrangement = ViewArrangement.RightResizable, + BorderStyle = LineStyle.Single, + X = 10, + Y = 10, + Width = 20, + Height = 10 + }; + + superView.Add (resizableView); + + // Verify initial state + Assert.NotNull (resizableView.Border); + Assert.Null (Application.Mouse.MouseGrabView); + + // Calculate position on right border (border is at right edge) + // Border.Frame.X is relative to parent, so we use coordinates relative to the border + var pressEvent = new MouseEventArgs + { + Position = new (resizableView.Border.Frame.Width - 1, 5), // Right border area + Flags = MouseFlags.Button1Pressed + }; + + bool? result = resizableView.Border.NewMouseEvent (pressEvent); + + // The border should have grabbed the mouse for resizing + Assert.True (result); + Assert.Equal (resizableView.Border, Application.Mouse.MouseGrabView); + + // Simulate dragging to resize + var dragEvent = new MouseEventArgs + { + Position = new (resizableView.Border.Frame.Width + 3, 5), + Flags = MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition + }; + + result = resizableView.Border.NewMouseEvent (dragEvent); + Assert.True (result); + Assert.Equal (resizableView.Border, Application.Mouse.MouseGrabView); + + // Simulate mouse release + var releaseEvent = new MouseEventArgs + { + Position = new (resizableView.Border.Frame.Width + 3, 5), + Flags = MouseFlags.Button1Released + }; + + result = resizableView.Border.NewMouseEvent (releaseEvent); + Assert.True (result); + + // Mouse should be released + Assert.Null (Application.Mouse.MouseGrabView); + } + + [Fact] + public void MouseGrabHandler_ReleasesOnMultipleViews () + { + // This test verifies MouseGrabHandler properly releases when switching between views + + var superView = new View { Width = 80, Height = 25 }; + + var view1 = new View + { + Arrangement = ViewArrangement.Movable, + BorderStyle = LineStyle.Single, + X = 10, + Y = 10, + Width = 15, + Height = 8 + }; + + var view2 = new View + { + Arrangement = ViewArrangement.Movable, + BorderStyle = LineStyle.Single, + X = 30, + Y = 10, + Width = 15, + Height = 8 + }; + + superView.Add (view1, view2); + + // Grab mouse on first view + var pressEvent1 = new MouseEventArgs + { + Position = new (1, 0), + Flags = MouseFlags.Button1Pressed + }; + + view1.Border!.NewMouseEvent (pressEvent1); + Assert.Equal (view1.Border, Application.Mouse.MouseGrabView); + + // Release on first view + var releaseEvent1 = new MouseEventArgs + { + Position = new (1, 0), + Flags = MouseFlags.Button1Released + }; + + view1.Border.NewMouseEvent (releaseEvent1); + Assert.Null (Application.Mouse.MouseGrabView); + + // Grab mouse on second view + var pressEvent2 = new MouseEventArgs + { + Position = new (1, 0), + Flags = MouseFlags.Button1Pressed + }; + + view2.Border!.NewMouseEvent (pressEvent2); + Assert.Equal (view2.Border, Application.Mouse.MouseGrabView); + + // Release on second view + var releaseEvent2 = new MouseEventArgs + { + Position = new (1, 0), + Flags = MouseFlags.Button1Released + }; + + view2.Border.NewMouseEvent (releaseEvent2); + Assert.Null (Application.Mouse.MouseGrabView); + } +} diff --git a/Tests/UnitTests/Views/ShortcutTests.cs b/Tests/UnitTests/Views/ShortcutTests.cs index 09b4e66d11..84eaa94193 100644 --- a/Tests/UnitTests/Views/ShortcutTests.cs +++ b/Tests/UnitTests/Views/ShortcutTests.cs @@ -473,4 +473,42 @@ public void Scheme_SetScheme_Does_Not_Fault_3664 () Application.Top.Dispose (); Application.ResetState (); } + + + // Test Key gets bound correctly + [Fact] + public void BindKeyToApplication_Defaults_To_HotKey () + { + var shortcut = new Shortcut (); + + Assert.False (shortcut.BindKeyToApplication); + } + + [Fact] + public void BindKeyToApplication_Can_Be_Set () + { + var shortcut = new Shortcut (); + + shortcut.BindKeyToApplication = true; + + Assert.True (shortcut.BindKeyToApplication); + } + + [Fact] + public void BindKeyToApplication_Changing_Adjusts_KeyBindings () + { + var shortcut = new Shortcut (); + + shortcut.Key = Key.A; + Assert.True (shortcut.HotKeyBindings.TryGet (Key.A, out _)); + + shortcut.BindKeyToApplication = true; + Assert.False (shortcut.HotKeyBindings.TryGet (Key.A, out _)); + Assert.True (Application.KeyBindings.TryGet (Key.A, out _)); + + shortcut.BindKeyToApplication = false; + Assert.True (shortcut.HotKeyBindings.TryGet (Key.A, out _)); + Assert.False (Application.KeyBindings.TryGet (Key.A, out _)); + } + } diff --git a/Tests/UnitTestsParallelizable/Configuration/SourcesManagerTests.cs b/Tests/UnitTestsParallelizable/Configuration/SourcesManagerTests.cs index 253acc8c04..f8ebf77e99 100644 --- a/Tests/UnitTestsParallelizable/Configuration/SourcesManagerTests.cs +++ b/Tests/UnitTestsParallelizable/Configuration/SourcesManagerTests.cs @@ -7,7 +7,7 @@ public class SourcesManagerTests #region Update (Stream) [Fact] - public void Update_WithNullSettingsScope_ReturnsFalse () + public void Load_WithNullSettingsScope_ReturnsFalse () { // Arrange var sourcesManager = new SourcesManager (); @@ -23,7 +23,7 @@ public void Update_WithNullSettingsScope_ReturnsFalse () } [Fact] - public void Update_WithValidStream_UpdatesSettingsScope () + public void Load_WithValidStream_UpdatesSettingsScope () { // Arrange var sourcesManager = new SourcesManager (); @@ -56,7 +56,7 @@ public void Update_WithValidStream_UpdatesSettingsScope () } [Fact] - public void Update_WithInvalidJson_AddsJsonError () + public void Load_WithInvalidJson_AddsJsonError () { // Arrange var sourcesManager = new SourcesManager (); @@ -86,7 +86,7 @@ public void Update_WithInvalidJson_AddsJsonError () #region Update (FilePath) [Fact] - public void Update_WithNonExistentFile_AddsToSourcesAndReturnsTrue () + public void Load_WithNonExistentFile_AddsToSourcesAndReturnsTrue () { // Arrange var sourcesManager = new SourcesManager (); @@ -104,7 +104,7 @@ public void Update_WithNonExistentFile_AddsToSourcesAndReturnsTrue () } [Fact] - public void Update_WithValidFile_UpdatesSettingsScope () + public void Load_WithValidFile_UpdatesSettingsScope () { // Arrange var sourcesManager = new SourcesManager (); @@ -140,7 +140,7 @@ public void Update_WithValidFile_UpdatesSettingsScope () } [Fact] - public void Update_WithIOException_RetriesAndFailsGracefully () + public void Load_WithIOException_RetriesAndFailsGracefully () { // Arrange var sourcesManager = new SourcesManager (); @@ -174,7 +174,7 @@ public void Update_WithIOException_RetriesAndFailsGracefully () #region Update (Json String) [Fact] - public void Update_WithNullOrEmptyJson_ReturnsFalse () + public void Load_WithNullOrEmptyJson_ReturnsFalse () { // Arrange var sourcesManager = new SourcesManager (); @@ -193,7 +193,7 @@ public void Update_WithNullOrEmptyJson_ReturnsFalse () } [Fact] - public void Update_WithValidJson_UpdatesSettingsScope () + public void Load_WithValidJson_UpdatesSettingsScope () { // Arrange var sourcesManager = new SourcesManager (); @@ -354,7 +354,7 @@ public void Sources_Dictionary_IsInitializedEmpty () } [Fact] - public void Update_WhenCalledMultipleTimes_MaintainsLastSourceForLocation () + public void Load_WhenCalledMultipleTimes_MaintainsLastSourceForLocation () { // Arrange var sourcesManager = new SourcesManager (); @@ -374,7 +374,7 @@ public void Update_WhenCalledMultipleTimes_MaintainsLastSourceForLocation () } [Fact] - public void Update_WithDifferentLocations_AddsAllSourcesToCollection () + public void Load_WithDifferentLocations_AddsAllSourcesToCollection () { // Arrange var sourcesManager = new SourcesManager (); @@ -425,7 +425,7 @@ public void Load_AddsResourceSourceToCollection () } [Fact] - public void Update_WithNonExistentFileAndDifferentLocations_TracksAllSources () + public void Load_WithNonExistentFileAndDifferentLocations_TracksAllSources () { // Arrange var sourcesManager = new SourcesManager (); diff --git a/Tests/UnitTestsParallelizable/View/ArrangementTests.cs b/Tests/UnitTestsParallelizable/View/ArrangementTests.cs index 842a7070b8..cc156b7967 100644 --- a/Tests/UnitTestsParallelizable/View/ArrangementTests.cs +++ b/Tests/UnitTestsParallelizable/View/ArrangementTests.cs @@ -28,11 +28,11 @@ public void ViewArrangement_Flags_HaveCorrectValues () [Fact] public void ViewArrangement_Resizable_IsCombinationOfAllResizableFlags () { - ViewArrangement expected = ViewArrangement.LeftResizable - | ViewArrangement.RightResizable - | ViewArrangement.TopResizable + ViewArrangement expected = ViewArrangement.LeftResizable + | ViewArrangement.RightResizable + | ViewArrangement.TopResizable | ViewArrangement.BottomResizable; - + Assert.Equal (ViewArrangement.Resizable, expected); } @@ -40,7 +40,7 @@ public void ViewArrangement_Resizable_IsCombinationOfAllResizableFlags () public void ViewArrangement_CanCombineFlags () { ViewArrangement arrangement = ViewArrangement.Movable | ViewArrangement.LeftResizable; - + Assert.True (arrangement.HasFlag (ViewArrangement.Movable)); Assert.True (arrangement.HasFlag (ViewArrangement.LeftResizable)); Assert.False (arrangement.HasFlag (ViewArrangement.RightResizable)); @@ -67,11 +67,11 @@ public void View_Arrangement_CanBeSet () [Fact] public void View_Arrangement_CanSetMultipleFlags () { - var view = new View - { - Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable + var view = new View + { + Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable }; - + Assert.True (view.Arrangement.HasFlag (ViewArrangement.Movable)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.LeftResizable)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.RightResizable)); @@ -83,7 +83,7 @@ public void View_Arrangement_CanSetMultipleFlags () public void View_Arrangement_Overlapped_CanBeSetIndependently () { var view = new View { Arrangement = ViewArrangement.Overlapped }; - + Assert.True (view.Arrangement.HasFlag (ViewArrangement.Overlapped)); Assert.False (view.Arrangement.HasFlag (ViewArrangement.Movable)); Assert.False (view.Arrangement.HasFlag (ViewArrangement.Resizable)); @@ -92,11 +92,11 @@ public void View_Arrangement_Overlapped_CanBeSetIndependently () [Fact] public void View_Arrangement_CanCombineOverlappedWithOtherFlags () { - var view = new View - { - Arrangement = ViewArrangement.Overlapped | ViewArrangement.Movable + var view = new View + { + Arrangement = ViewArrangement.Overlapped | ViewArrangement.Movable }; - + Assert.True (view.Arrangement.HasFlag (ViewArrangement.Overlapped)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.Movable)); } @@ -108,12 +108,12 @@ public void View_Arrangement_CanCombineOverlappedWithOtherFlags () [Fact] public void TopResizable_WithoutMovable_IsAllowed () { - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.TopResizable, BorderStyle = LineStyle.Single }; - + Assert.True (view.Arrangement.HasFlag (ViewArrangement.TopResizable)); Assert.False (view.Arrangement.HasFlag (ViewArrangement.Movable)); } @@ -123,16 +123,16 @@ public void Movable_WithTopResizable_MovableWins () { // According to docs and Border.Arrangment.cs line 569: // TopResizable is only checked if NOT Movable - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.Movable | ViewArrangement.TopResizable, BorderStyle = LineStyle.Single }; - + // Both flags can be set on the property Assert.True (view.Arrangement.HasFlag (ViewArrangement.Movable)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.TopResizable)); - + // But the behavior in Border.DetermineArrangeModeFromClick // will prioritize Movable over TopResizable } @@ -140,12 +140,12 @@ public void Movable_WithTopResizable_MovableWins () [Fact] public void Resizable_WithMovable_IncludesTopResizable () { - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.Resizable | ViewArrangement.Movable, BorderStyle = LineStyle.Single }; - + Assert.True (view.Arrangement.HasFlag (ViewArrangement.Movable)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.TopResizable)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.LeftResizable)); @@ -160,12 +160,12 @@ public void Resizable_WithMovable_IncludesTopResizable () [Fact] public void Border_WithNoArrangement_HasNoArrangementOptions () { - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.Fixed, BorderStyle = LineStyle.Single }; - + Assert.NotNull (view.Border); Assert.Equal (ViewArrangement.Fixed, view.Arrangement); } @@ -174,8 +174,8 @@ public void Border_WithNoArrangement_HasNoArrangementOptions () public void Border_WithMovableArrangement_CanEnterArrangeMode () { var superView = new View (); - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.Movable, BorderStyle = LineStyle.Single, X = 0, @@ -184,7 +184,7 @@ public void Border_WithMovableArrangement_CanEnterArrangeMode () Height = 10 }; superView.Add (view); - + Assert.NotNull (view.Border); Assert.True (view.Arrangement.HasFlag (ViewArrangement.Movable)); } @@ -192,12 +192,12 @@ public void Border_WithMovableArrangement_CanEnterArrangeMode () [Fact] public void Border_WithResizableArrangement_HasResizableOptions () { - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.Resizable, BorderStyle = LineStyle.Single }; - + Assert.NotNull (view.Border); Assert.True (view.Arrangement.HasFlag (ViewArrangement.LeftResizable)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.RightResizable)); @@ -212,15 +212,15 @@ public void Border_WithResizableArrangement_HasResizableOptions () [InlineData (ViewArrangement.BottomResizable)] public void Border_WithSingleResizableDirection_OnlyHasThatOption (ViewArrangement arrangement) { - var view = new View - { + var view = new View + { Arrangement = arrangement, BorderStyle = LineStyle.Single }; - + Assert.NotNull (view.Border); Assert.True (view.Arrangement.HasFlag (arrangement)); - + // Verify other directions are not set if (arrangement != ViewArrangement.LeftResizable) Assert.False (view.Arrangement.HasFlag (ViewArrangement.LeftResizable)); @@ -239,12 +239,12 @@ public void Border_WithSingleResizableDirection_OnlyHasThatOption (ViewArrangeme [Fact] public void Border_BottomRightResizable_CombinesBothFlags () { - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.BottomResizable | ViewArrangement.RightResizable, BorderStyle = LineStyle.Single }; - + Assert.True (view.Arrangement.HasFlag (ViewArrangement.BottomResizable)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.RightResizable)); Assert.False (view.Arrangement.HasFlag (ViewArrangement.TopResizable)); @@ -254,12 +254,12 @@ public void Border_BottomRightResizable_CombinesBothFlags () [Fact] public void Border_BottomLeftResizable_CombinesBothFlags () { - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.BottomResizable | ViewArrangement.LeftResizable, BorderStyle = LineStyle.Single }; - + Assert.True (view.Arrangement.HasFlag (ViewArrangement.BottomResizable)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.LeftResizable)); Assert.False (view.Arrangement.HasFlag (ViewArrangement.TopResizable)); @@ -269,12 +269,12 @@ public void Border_BottomLeftResizable_CombinesBothFlags () [Fact] public void Border_TopRightResizable_CombinesBothFlags () { - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.TopResizable | ViewArrangement.RightResizable, BorderStyle = LineStyle.Single }; - + Assert.True (view.Arrangement.HasFlag (ViewArrangement.TopResizable)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.RightResizable)); Assert.False (view.Arrangement.HasFlag (ViewArrangement.BottomResizable)); @@ -284,12 +284,12 @@ public void Border_TopRightResizable_CombinesBothFlags () [Fact] public void Border_TopLeftResizable_CombinesBothFlags () { - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.TopResizable | ViewArrangement.LeftResizable, BorderStyle = LineStyle.Single }; - + Assert.True (view.Arrangement.HasFlag (ViewArrangement.TopResizable)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.LeftResizable)); Assert.False (view.Arrangement.HasFlag (ViewArrangement.BottomResizable)); @@ -326,8 +326,8 @@ public void MoveSubViewToEnd_ViewArrangement (ViewArrangement arrangement) [Fact] public void Overlapped_AllowsSubViewsToOverlap () { - var superView = new View - { + var superView = new View + { Arrangement = ViewArrangement.Overlapped, Width = 20, Height = 20 @@ -351,7 +351,7 @@ public void Overlapped_AllowsSubViewsToOverlap () public void LeftResizable_CanBeUsedForHorizontalSplitter () { var container = new View { Width = 80, Height = 25 }; - + var leftPane = new View { X = 0, @@ -359,7 +359,7 @@ public void LeftResizable_CanBeUsedForHorizontalSplitter () Width = 40, Height = Dim.Fill () }; - + var rightPane = new View { X = 40, @@ -369,9 +369,9 @@ public void LeftResizable_CanBeUsedForHorizontalSplitter () Arrangement = ViewArrangement.LeftResizable, BorderStyle = LineStyle.Single }; - + container.Add (leftPane, rightPane); - + Assert.True (rightPane.Arrangement.HasFlag (ViewArrangement.LeftResizable)); Assert.NotNull (rightPane.Border); } @@ -380,7 +380,7 @@ public void LeftResizable_CanBeUsedForHorizontalSplitter () public void TopResizable_CanBeUsedForVerticalSplitter () { var container = new View { Width = 80, Height = 25 }; - + var topPane = new View { X = 0, @@ -388,7 +388,7 @@ public void TopResizable_CanBeUsedForVerticalSplitter () Width = Dim.Fill (), Height = 10 }; - + var bottomPane = new View { X = 0, @@ -398,9 +398,9 @@ public void TopResizable_CanBeUsedForVerticalSplitter () Arrangement = ViewArrangement.TopResizable, BorderStyle = LineStyle.Single }; - + container.Add (topPane, bottomPane); - + Assert.True (bottomPane.Arrangement.HasFlag (ViewArrangement.TopResizable)); Assert.NotNull (bottomPane.Border); } @@ -412,11 +412,11 @@ public void TopResizable_CanBeUsedForVerticalSplitter () [Fact] public void View_WithoutBorderStyle_CanHaveArrangement () { - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.Movable }; - + // Arrangement can be set even without a border style // Border object still exists but has no visible style Assert.Equal (ViewArrangement.Movable, view.Arrangement); @@ -427,11 +427,11 @@ public void View_WithoutBorderStyle_CanHaveArrangement () [Fact] public void View_WithNoBorderStyle_ResizableCanBeSet () { - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.Resizable }; - + // Arrangement is set but has limited effect without a visible border style Assert.Equal (ViewArrangement.Resizable, view.Arrangement); Assert.NotNull (view.Border); @@ -448,8 +448,8 @@ public void DetermineArrangeModeFromClick_TopResizableIgnoredWhenMovable () // This test verifies the documented behavior that TopResizable is ignored // when Movable is also set (line 569 in Border.Arrangment.cs) var superView = new View { Width = 80, Height = 25 }; - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.TopResizable | ViewArrangement.Movable, BorderStyle = LineStyle.Single, X = 10, @@ -458,11 +458,11 @@ public void DetermineArrangeModeFromClick_TopResizableIgnoredWhenMovable () Height = 10 }; superView.Add (view); - + // The view has both flags set Assert.True (view.Arrangement.HasFlag (ViewArrangement.TopResizable)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.Movable)); - + // But Movable takes precedence in Border.DetermineArrangeModeFromClick // This is verified by the code at line 569 checking !Parent!.Arrangement.HasFlag(ViewArrangement.Movable) } @@ -471,8 +471,8 @@ public void DetermineArrangeModeFromClick_TopResizableIgnoredWhenMovable () public void DetermineArrangeModeFromClick_TopResizableWorksWithoutMovable () { var superView = new View { Width = 80, Height = 25 }; - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.TopResizable, BorderStyle = LineStyle.Single, X = 10, @@ -481,7 +481,7 @@ public void DetermineArrangeModeFromClick_TopResizableWorksWithoutMovable () Height = 10 }; superView.Add (view); - + // Only TopResizable is set Assert.True (view.Arrangement.HasFlag (ViewArrangement.TopResizable)); Assert.False (view.Arrangement.HasFlag (ViewArrangement.Movable)); @@ -491,20 +491,20 @@ public void DetermineArrangeModeFromClick_TopResizableWorksWithoutMovable () public void DetermineArrangeModeFromClick_AllCornerCombinationsSupported () { var superView = new View { Width = 80, Height = 25 }; - + // Test that all 4 corner combinations are recognized - var cornerCombinations = new[] + var cornerCombinations = new [] { ViewArrangement.BottomResizable | ViewArrangement.RightResizable, ViewArrangement.BottomResizable | ViewArrangement.LeftResizable, ViewArrangement.TopResizable | ViewArrangement.RightResizable, ViewArrangement.TopResizable | ViewArrangement.LeftResizable }; - + foreach (var arrangement in cornerCombinations) { - var view = new View - { + var view = new View + { Arrangement = arrangement, BorderStyle = LineStyle.Single, X = 10, @@ -513,10 +513,10 @@ public void DetermineArrangeModeFromClick_AllCornerCombinationsSupported () Height = 10 }; superView.Add (view); - + // Verify the flags are set correctly Assert.True (view.Arrangement == arrangement); - + superView.Remove (view); } } @@ -530,10 +530,10 @@ public void View_Arrangement_CanBeChangedAfterCreation () { var view = new View { Arrangement = ViewArrangement.Fixed }; Assert.Equal (ViewArrangement.Fixed, view.Arrangement); - + view.Arrangement = ViewArrangement.Movable; Assert.Equal (ViewArrangement.Movable, view.Arrangement); - + view.Arrangement = ViewArrangement.Resizable; Assert.Equal (ViewArrangement.Resizable, view.Arrangement); } @@ -542,7 +542,7 @@ public void View_Arrangement_CanBeChangedAfterCreation () public void View_Arrangement_CanAddFlags () { var view = new View { Arrangement = ViewArrangement.Movable }; - + view.Arrangement |= ViewArrangement.LeftResizable; Assert.True (view.Arrangement.HasFlag (ViewArrangement.Movable)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.LeftResizable)); @@ -551,11 +551,11 @@ public void View_Arrangement_CanAddFlags () [Fact] public void View_Arrangement_CanRemoveFlags () { - var view = new View - { - Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable + var view = new View + { + Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable }; - + view.Arrangement &= ~ViewArrangement.Movable; Assert.False (view.Arrangement.HasFlag (ViewArrangement.Movable)); Assert.True (view.Arrangement.HasFlag (ViewArrangement.Resizable)); @@ -568,15 +568,15 @@ public void View_Arrangement_CanRemoveFlags () [Fact] public void SuperView_CanHaveMultipleArrangeableSubViews () { - var superView = new View - { + var superView = new View + { Arrangement = ViewArrangement.Overlapped, Width = 80, Height = 25 }; - - var movableView = new View - { + + var movableView = new View + { Arrangement = ViewArrangement.Movable, BorderStyle = LineStyle.Single, X = 0, @@ -584,9 +584,9 @@ public void SuperView_CanHaveMultipleArrangeableSubViews () Width = 20, Height = 10 }; - - var resizableView = new View - { + + var resizableView = new View + { Arrangement = ViewArrangement.Resizable, BorderStyle = LineStyle.Single, X = 25, @@ -594,9 +594,9 @@ public void SuperView_CanHaveMultipleArrangeableSubViews () Width = 20, Height = 10 }; - - var fixedView = new View - { + + var fixedView = new View + { Arrangement = ViewArrangement.Fixed, BorderStyle = LineStyle.Single, X = 50, @@ -604,9 +604,9 @@ public void SuperView_CanHaveMultipleArrangeableSubViews () Width = 20, Height = 10 }; - + superView.Add (movableView, resizableView, fixedView); - + Assert.Equal (3, superView.SubViews.Count); Assert.Equal (ViewArrangement.Movable, movableView.Arrangement); Assert.Equal (ViewArrangement.Resizable, resizableView.Arrangement); @@ -618,9 +618,9 @@ public void SubView_ArrangementIndependentOfSuperView () { var superView = new View { Arrangement = ViewArrangement.Fixed }; var subView = new View { Arrangement = ViewArrangement.Movable }; - + superView.Add (subView); - + // SubView arrangement is independent of SuperView arrangement Assert.Equal (ViewArrangement.Fixed, superView.Arrangement); Assert.Equal (ViewArrangement.Movable, subView.Arrangement); @@ -633,30 +633,30 @@ public void SubView_ArrangementIndependentOfSuperView () [Fact] public void Border_WithDefaultThickness_SupportsArrangement () { - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.Movable, BorderStyle = LineStyle.Single }; - + Assert.NotNull (view.Border); // Default thickness should be (1,1,1,1) for Single line style - Assert.True (view.Border.Thickness.Left > 0 || view.Border.Thickness.Right > 0 + Assert.True (view.Border.Thickness.Left > 0 || view.Border.Thickness.Right > 0 || view.Border.Thickness.Top > 0 || view.Border.Thickness.Bottom > 0); } [Fact] public void Border_WithCustomThickness_SupportsArrangement () { - var view = new View - { + var view = new View + { Arrangement = ViewArrangement.LeftResizable, BorderStyle = LineStyle.Single }; - + // Set custom thickness - only left border view.Border!.Thickness = new Thickness (2, 0, 0, 0); - + Assert.Equal (2, view.Border.Thickness.Left); Assert.Equal (0, view.Border.Thickness.Top); Assert.Equal (0, view.Border.Thickness.Right); @@ -696,7 +696,7 @@ public void View_Navigation_RespectsOverlappedFlag () // View.Navigation.cs checks Arrangement.HasFlag(ViewArrangement.Overlapped) var overlappedView = new View { Arrangement = ViewArrangement.Overlapped }; var tiledView = new View { Arrangement = ViewArrangement.Fixed }; - + Assert.True (overlappedView.Arrangement.HasFlag (ViewArrangement.Overlapped)); Assert.False (tiledView.Arrangement.HasFlag (ViewArrangement.Overlapped)); } @@ -708,9 +708,9 @@ public void View_Hierarchy_RespectsOverlappedFlag () var parent = new View { Arrangement = ViewArrangement.Overlapped }; var child1 = new View { X = 0, Y = 0, Width = 10, Height = 10 }; var child2 = new View { X = 5, Y = 5, Width = 10, Height = 10 }; - + parent.Add (child1, child2); - + Assert.True (parent.Arrangement.HasFlag (ViewArrangement.Overlapped)); Assert.Equal (2, parent.SubViews.Count); } @@ -719,209 +719,7 @@ public void View_Hierarchy_RespectsOverlappedFlag () #region Mouse Interaction Tests - [Fact] - public void MouseGrabHandler_WorksWithMovableView_UsingNewMouseEvent () - { - // This test proves that MouseGrabHandler works correctly with concurrent unit tests - // using NewMouseEvent directly on views, without requiring Application.Init - - var superView = new View - { - Width = 80, - Height = 25 - }; - - var movableView = new View - { - Arrangement = ViewArrangement.Movable, - BorderStyle = LineStyle.Single, - X = 10, - Y = 10, - Width = 20, - Height = 10 - }; - - superView.Add (movableView); - - // Verify initial state - Assert.NotNull (movableView.Border); - Assert.Null (Application.Mouse.MouseGrabView); - - // Simulate mouse press on the border to start dragging - var pressEvent = new MouseEventArgs - { - Position = new (1, 0), // Top border area - Flags = MouseFlags.Button1Pressed - }; - - bool? result = movableView.Border.NewMouseEvent (pressEvent); - - // The border should have grabbed the mouse - Assert.True (result); - Assert.Equal (movableView.Border, Application.Mouse.MouseGrabView); - - // Simulate mouse drag - var dragEvent = new MouseEventArgs - { - Position = new (5, 2), - Flags = MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition - }; - - result = movableView.Border.NewMouseEvent (dragEvent); - Assert.True (result); - - // Mouse should still be grabbed - Assert.Equal (movableView.Border, Application.Mouse.MouseGrabView); - - // Simulate mouse release to end dragging - var releaseEvent = new MouseEventArgs - { - Position = new (5, 2), - Flags = MouseFlags.Button1Released - }; - - result = movableView.Border.NewMouseEvent (releaseEvent); - Assert.True (result); - - // Mouse should be released - Assert.Null (Application.Mouse.MouseGrabView); - } - - [Fact] - public void MouseGrabHandler_WorksWithResizableView_UsingNewMouseEvent () - { - // This test proves MouseGrabHandler works for resizing operations - - var superView = new View - { - Width = 80, - Height = 25 - }; - - var resizableView = new View - { - Arrangement = ViewArrangement.RightResizable, - BorderStyle = LineStyle.Single, - X = 10, - Y = 10, - Width = 20, - Height = 10 - }; - - superView.Add (resizableView); - - // Verify initial state - Assert.NotNull (resizableView.Border); - Assert.Null (Application.Mouse.MouseGrabView); - - // Calculate position on right border (border is at right edge) - // Border.Frame.X is relative to parent, so we use coordinates relative to the border - var pressEvent = new MouseEventArgs - { - Position = new (resizableView.Border.Frame.Width - 1, 5), // Right border area - Flags = MouseFlags.Button1Pressed - }; - - bool? result = resizableView.Border.NewMouseEvent (pressEvent); - - // The border should have grabbed the mouse for resizing - Assert.True (result); - Assert.Equal (resizableView.Border, Application.Mouse.MouseGrabView); - - // Simulate dragging to resize - var dragEvent = new MouseEventArgs - { - Position = new (resizableView.Border.Frame.Width + 3, 5), - Flags = MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition - }; - - result = resizableView.Border.NewMouseEvent (dragEvent); - Assert.True (result); - Assert.Equal (resizableView.Border, Application.Mouse.MouseGrabView); - - // Simulate mouse release - var releaseEvent = new MouseEventArgs - { - Position = new (resizableView.Border.Frame.Width + 3, 5), - Flags = MouseFlags.Button1Released - }; - - result = resizableView.Border.NewMouseEvent (releaseEvent); - Assert.True (result); - - // Mouse should be released - Assert.Null (Application.Mouse.MouseGrabView); - } - - [Fact] - public void MouseGrabHandler_ReleasesOnMultipleViews () - { - // This test verifies MouseGrabHandler properly releases when switching between views - - var superView = new View { Width = 80, Height = 25 }; - - var view1 = new View - { - Arrangement = ViewArrangement.Movable, - BorderStyle = LineStyle.Single, - X = 10, - Y = 10, - Width = 15, - Height = 8 - }; - - var view2 = new View - { - Arrangement = ViewArrangement.Movable, - BorderStyle = LineStyle.Single, - X = 30, - Y = 10, - Width = 15, - Height = 8 - }; - - superView.Add (view1, view2); - - // Grab mouse on first view - var pressEvent1 = new MouseEventArgs - { - Position = new (1, 0), - Flags = MouseFlags.Button1Pressed - }; - - view1.Border!.NewMouseEvent (pressEvent1); - Assert.Equal (view1.Border, Application.Mouse.MouseGrabView); - - // Release on first view - var releaseEvent1 = new MouseEventArgs - { - Position = new (1, 0), - Flags = MouseFlags.Button1Released - }; - - view1.Border.NewMouseEvent (releaseEvent1); - Assert.Null (Application.Mouse.MouseGrabView); - - // Grab mouse on second view - var pressEvent2 = new MouseEventArgs - { - Position = new (1, 0), - Flags = MouseFlags.Button1Pressed - }; - - view2.Border!.NewMouseEvent (pressEvent2); - Assert.Equal (view2.Border, Application.Mouse.MouseGrabView); - - // Release on second view - var releaseEvent2 = new MouseEventArgs - { - Position = new (1, 0), - Flags = MouseFlags.Button1Released - }; - - view2.Border.NewMouseEvent (releaseEvent2); - Assert.Null (Application.Mouse.MouseGrabView); - } + // Not parallelizable due to Application.Mouse dependency in MouseGrabHandler #endregion @@ -954,13 +752,13 @@ public void AllArrangementTests_AreParallelizable () // This test verifies that all the arrangement tests in this file // can run without Application.Init, making them parallelizable. // If this test passes, it confirms no Application dependencies leaked in. - - var view = new View - { + + var view = new View + { Arrangement = ViewArrangement.Movable | ViewArrangement.Resizable, BorderStyle = LineStyle.Single }; - + Assert.NotNull (view); Assert.NotNull (view.Border); Assert.True (view.Arrangement.HasFlag (ViewArrangement.Movable)); @@ -974,9 +772,9 @@ public void AllArrangementTests_AreParallelizable () [Fact] public void ViewArrangement_CanCombineAllResizableDirections () { - ViewArrangement arrangement = ViewArrangement.TopResizable - | ViewArrangement.BottomResizable - | ViewArrangement.LeftResizable + ViewArrangement arrangement = ViewArrangement.TopResizable + | ViewArrangement.BottomResizable + | ViewArrangement.LeftResizable | ViewArrangement.RightResizable; Assert.True (arrangement.HasFlag (ViewArrangement.TopResizable)); @@ -1090,7 +888,7 @@ public void View_ChangingToNoBorderStyle_PreservesArrangement () public void View_MultipleSubviewsWithDifferentArrangements_EachIndependent () { var container = new View (); - + var fixedView = new View { Id = "fixed", Arrangement = ViewArrangement.Fixed }; var movableView = new View { Id = "movable", Arrangement = ViewArrangement.Movable }; var resizableView = new View { Id = "resizable", Arrangement = ViewArrangement.Resizable }; @@ -1112,7 +910,7 @@ public void View_MultipleSubviewsWithDifferentArrangements_EachIndependent () public void Overlapped_ViewCanBeMovedToFront () { var container = new View { Arrangement = ViewArrangement.Overlapped }; - + var view1 = new View { Id = "view1" }; var view2 = new View { Id = "view2" }; var view3 = new View { Id = "view3" }; @@ -1133,7 +931,7 @@ public void Overlapped_ViewCanBeMovedToFront () public void Overlapped_ViewCanBeMovedToBack () { var container = new View { Arrangement = ViewArrangement.Overlapped }; - + var view1 = new View { Id = "view1" }; var view2 = new View { Id = "view2" }; var view3 = new View { Id = "view3" }; @@ -1142,12 +940,12 @@ public void Overlapped_ViewCanBeMovedToBack () // Initial order: [view1, view2, view3] Assert.Equal ([view1, view2, view3], container.SubViews.ToArray ()); - + // Move view3 to end (top of Z-order) container.MoveSubViewToEnd (view3); Assert.Equal (view3, container.SubViews.ToArray () [^1]); Assert.Equal ([view1, view2, view3], container.SubViews.ToArray ()); - + // Now move view1 to end (making it on top, pushing view3 down) container.MoveSubViewToEnd (view1); Assert.Equal ([view2, view3, view1], container.SubViews.ToArray ()); @@ -1157,7 +955,7 @@ public void Overlapped_ViewCanBeMovedToBack () public void Fixed_ViewAddOrderMattersForLayout () { var container = new View { Arrangement = ViewArrangement.Fixed }; - + var view1 = new View { Id = "view1", X = 0, Y = 0, Width = 10, Height = 5 }; var view2 = new View { Id = "view2", X = 5, Y = 2, Width = 10, Height = 5 }; diff --git a/Tests/UnitTestsParallelizable/View/Draw/NeedsDrawTests.cs b/Tests/UnitTestsParallelizable/View/Draw/NeedsDrawTests.cs index cf3b7a0c8b..04493c4b56 100644 --- a/Tests/UnitTestsParallelizable/View/Draw/NeedsDrawTests.cs +++ b/Tests/UnitTestsParallelizable/View/Draw/NeedsDrawTests.cs @@ -54,7 +54,7 @@ public void NeedsDraw_True_After_Constructor () var view = new View { Width = 2, Height = 2 }; Assert.True (view.NeedsDraw); - view = new() { Width = 2, Height = 2, BorderStyle = LineStyle.Single }; + view = new () { Width = 2, Height = 2, BorderStyle = LineStyle.Single }; Assert.True (view.NeedsDraw); } @@ -90,7 +90,7 @@ public void NeedsDraw_True_After_EndInit_Where_Call_Layout () view.EndInit (); Assert.True (view.NeedsDraw); - view = new() { Width = 2, Height = 2, BorderStyle = LineStyle.Single }; + view = new () { Width = 2, Height = 2, BorderStyle = LineStyle.Single }; view.BeginInit (); view.NeedsDraw = false; view.EndInit (); @@ -128,14 +128,14 @@ public void NeedsDraw_False_After_SetRelativeLayout_Absolute_Dims () Assert.False (view.NeedsLayout); // SRL won't change anything since the view frame wasn't changed - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.False (view.NeedsDraw); view.SetNeedsLayout (); // SRL won't change anything since the view frame wasn't changed // SRL doesn't depend on NeedsLayout, but LayoutSubViews does - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.False (view.NeedsDraw); Assert.True (view.NeedsLayout); @@ -178,7 +178,7 @@ public void NeedsDraw_False_After_SetRelativeLayout_Relative_Dims () Assert.True (view.NeedsDraw); Assert.True (superView.NeedsDraw); - superView.SetRelativeLayout (Application.Screen.Size); + superView.SetRelativeLayout (new (100, 100)); Assert.True (view.NeedsDraw); Assert.True (superView.NeedsDraw); } @@ -214,7 +214,7 @@ public void NeedsDraw_True_After_LayoutSubViews () view.EndInit (); Assert.True (view.NeedsDraw); - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.True (view.NeedsDraw); view.LayoutSubViews (); @@ -233,7 +233,7 @@ public void NeedsDraw_False_After_Draw () view.EndInit (); Assert.True (view.NeedsDraw); - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.True (view.NeedsDraw); view.LayoutSubViews (); diff --git a/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.PosTypes.cs b/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.PosTypes.cs index 0078736c5b..61aadaea74 100644 --- a/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.PosTypes.cs +++ b/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.PosTypes.cs @@ -597,7 +597,7 @@ public void With_Text_And_SubView_Using_PosAnchorEnd (int minWidth, int maxWidth // Without a subview, width should be 10 // Without a subview, height should be 1 - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.Equal (10, view.Frame.Width); Assert.Equal (1, view.Frame.Height); diff --git a/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.cs b/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.cs index b42b0bc9c8..4c1da89b56 100644 --- a/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.cs +++ b/Tests/UnitTestsParallelizable/View/Layout/Dim.AutoTests.cs @@ -304,10 +304,10 @@ public void TextFormatter_Settings_Change_View_Size () Width = Auto (), Height = 1 }; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); lastSize = view.Frame.Size; view.HotKeySpecifier = (Rune)'*'; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.NotEqual (lastSize, view.Frame.Size); view = new () @@ -316,10 +316,10 @@ public void TextFormatter_Settings_Change_View_Size () Width = Auto (), Height = 1 }; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); lastSize = view.Frame.Size; view.Text = "*ABCD"; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.NotEqual (lastSize, view.Frame.Size); } @@ -703,7 +703,7 @@ public void DimAutoStyle_Auto_JustText_Sizes_Correctly (string text, int expecte view.Text = text; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.Equal (new (expectedW, expectedH), view.Frame.Size); } @@ -812,7 +812,7 @@ public void DimAutoStyle_Text_Sizes_Correctly (string text, int expectedW, int e view.Text = text; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.Equal (new (expectedW, expectedH), view.Frame.Size); } @@ -831,7 +831,7 @@ public void DimAutoStyle_Text_Sizes_Correctly_With_Min (string text, int minWidt view.Text = text; - view.SetRelativeLayout (Application.Screen.Size); + view.SetRelativeLayout (new (100, 100)); Assert.Equal (new (expectedW, expectedH), view.Frame.Size); } diff --git a/Tests/UnitTestsParallelizable/View/Layout/FrameTests.cs b/Tests/UnitTestsParallelizable/View/Layout/FrameTests.cs index 364012cc39..1f9b24a90b 100644 --- a/Tests/UnitTestsParallelizable/View/Layout/FrameTests.cs +++ b/Tests/UnitTestsParallelizable/View/Layout/FrameTests.cs @@ -120,7 +120,7 @@ public void Frame_Empty_Initializer_Sets () Assert.True (view.NeedsLayout); view.Layout (); Assert.False (view.NeedsLayout); - Assert.Equal (Application.Screen, view.Frame); + Assert.Equal (new Size (2048, 2048), view.Frame.Size); view.Frame = Rectangle.Empty; Assert.Equal (Rectangle.Empty, view.Frame); @@ -165,7 +165,7 @@ public void Frame_Set () Assert.Equal (Rectangle.Empty, v.Frame); v.Dispose (); - v = new() { Frame = frame }; + v = new () { Frame = frame }; Assert.Equal (frame, v.Frame); v.Frame = newFrame; @@ -181,7 +181,7 @@ public void Frame_Set () Assert.Equal (Dim.Absolute (40), v.Height); v.Dispose (); - v = new() { X = frame.X, Y = frame.Y, Text = "v" }; + v = new () { X = frame.X, Y = frame.Y, Text = "v" }; v.Frame = newFrame; Assert.Equal (newFrame, v.Frame); @@ -196,7 +196,7 @@ public void Frame_Set () v.Dispose (); newFrame = new (10, 20, 30, 40); - v = new() { Frame = frame }; + v = new () { Frame = frame }; v.Frame = newFrame; Assert.Equal (newFrame, v.Frame); @@ -210,7 +210,7 @@ public void Frame_Set () Assert.Equal (Dim.Absolute (40), v.Height); v.Dispose (); - v = new() { X = frame.X, Y = frame.Y, Text = "v" }; + v = new () { X = frame.X, Y = frame.Y, Text = "v" }; v.Frame = newFrame; Assert.Equal (newFrame, v.Frame); diff --git a/Tests/UnitTestsParallelizable/View/SubviewTests.cs b/Tests/UnitTestsParallelizable/View/SubviewTests.cs index f02650d549..a447acd6e9 100644 --- a/Tests/UnitTestsParallelizable/View/SubviewTests.cs +++ b/Tests/UnitTestsParallelizable/View/SubviewTests.cs @@ -14,11 +14,11 @@ public void SuperViewChanged_Raised_On_Add () super.SuperViewChanged += (s, e) => { - superRaisedCount++; + superRaisedCount++; }; sub.SuperViewChanged += (s, e) => { - if (e.SuperView is {}) + if (e.SuperView is { }) { subRaisedCount++; } @@ -266,14 +266,14 @@ public void MoveSubViewTowardsEnd () superView.Add (subview1, subview2, subview3); superView.MoveSubViewTowardsEnd (subview2); - Assert.Equal (subview2, superView.SubViews.ToArray() [^1]); + Assert.Equal (subview2, superView.SubViews.ToArray () [^1]); superView.MoveSubViewTowardsEnd (subview1); - Assert.Equal (subview1, superView.SubViews.ToArray() [1]); + Assert.Equal (subview1, superView.SubViews.ToArray () [1]); // Already at end, what happens? superView.MoveSubViewTowardsEnd (subview2); - Assert.Equal (subview2, superView.SubViews.ToArray() [^1]); + Assert.Equal (subview2, superView.SubViews.ToArray () [^1]); } [Fact] @@ -517,7 +517,7 @@ public void Initialized_Event_Comparing_With_Added_Event () Assert.False (v2AddedToWin.CanFocus); Assert.False (svAddedTov1.CanFocus); - Application.LayoutAndDraw (); + top.Layout (); }; winAddedToTop.Initialized += (s, e) => diff --git a/Tests/UnitTestsParallelizable/Views/NumericUpDownTests.cs b/Tests/UnitTestsParallelizable/Views/NumericUpDownTests.cs index 4a99752449..7e72ebe5f9 100644 --- a/Tests/UnitTestsParallelizable/Views/NumericUpDownTests.cs +++ b/Tests/UnitTestsParallelizable/Views/NumericUpDownTests.cs @@ -112,7 +112,7 @@ public void WhenCreatedWithValidNumberType_ShouldThrowInvalidOperationException_ public void WhenCreated_ShouldHaveDefaultWidthAndHeight_int () { NumericUpDown numericUpDown = new (); - numericUpDown.SetRelativeLayout (Application.Screen.Size); + numericUpDown.SetRelativeLayout (new (100, 100)); Assert.Equal (3, numericUpDown.Frame.Width); Assert.Equal (1, numericUpDown.Frame.Height); @@ -122,7 +122,7 @@ public void WhenCreated_ShouldHaveDefaultWidthAndHeight_int () public void WhenCreated_ShouldHaveDefaultWidthAndHeight_float () { NumericUpDown numericUpDown = new (); - numericUpDown.SetRelativeLayout (Application.Screen.Size); + numericUpDown.SetRelativeLayout (new (100, 100)); Assert.Equal (3, numericUpDown.Frame.Width); Assert.Equal (1, numericUpDown.Frame.Height); @@ -132,7 +132,7 @@ public void WhenCreated_ShouldHaveDefaultWidthAndHeight_float () public void WhenCreated_ShouldHaveDefaultWidthAndHeight_double () { NumericUpDown numericUpDown = new (); - numericUpDown.SetRelativeLayout (Application.Screen.Size); + numericUpDown.SetRelativeLayout (new (100, 100)); Assert.Equal (3, numericUpDown.Frame.Width); Assert.Equal (1, numericUpDown.Frame.Height); @@ -142,7 +142,7 @@ public void WhenCreated_ShouldHaveDefaultWidthAndHeight_double () public void WhenCreated_ShouldHaveDefaultWidthAndHeight_long () { NumericUpDown numericUpDown = new (); - numericUpDown.SetRelativeLayout (Application.Screen.Size); + numericUpDown.SetRelativeLayout (new (100, 100)); Assert.Equal (3, numericUpDown.Frame.Width); Assert.Equal (1, numericUpDown.Frame.Height); @@ -152,7 +152,7 @@ public void WhenCreated_ShouldHaveDefaultWidthAndHeight_long () public void WhenCreated_ShouldHaveDefaultWidthAndHeight_decimal () { NumericUpDown numericUpDown = new (); - numericUpDown.SetRelativeLayout (Application.Screen.Size); + numericUpDown.SetRelativeLayout (new (100, 100)); Assert.Equal (3, numericUpDown.Frame.Width); Assert.Equal (1, numericUpDown.Frame.Height); diff --git a/Tests/UnitTestsParallelizable/Views/ShortcutTests.cs b/Tests/UnitTestsParallelizable/Views/ShortcutTests.cs index 7866055ca9..46086946e2 100644 --- a/Tests/UnitTestsParallelizable/Views/ShortcutTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ShortcutTests.cs @@ -108,7 +108,7 @@ public void NaturalSize (string command, string help, KeyCode key, int expectedW // | C H K | Assert.Equal (expectedWidth, shortcut.Frame.Width); - shortcut = new() + shortcut = new () { HelpText = help, Title = command, @@ -118,7 +118,7 @@ public void NaturalSize (string command, string help, KeyCode key, int expectedW shortcut.Layout (); Assert.Equal (expectedWidth, shortcut.Frame.Width); - shortcut = new() + shortcut = new () { HelpText = help, Key = key, @@ -128,7 +128,7 @@ public void NaturalSize (string command, string help, KeyCode key, int expectedW shortcut.Layout (); Assert.Equal (expectedWidth, shortcut.Frame.Width); - shortcut = new() + shortcut = new () { Key = key, HelpText = help, @@ -287,42 +287,6 @@ public void Key_Changing_Removes_Previous_Binding () Assert.True (shortcut.HotKeyBindings.TryGet (Key.B, out _)); } - // Test Key gets bound correctly - [Fact] - public void BindKeyToApplication_Defaults_To_HotKey () - { - var shortcut = new Shortcut (); - - Assert.False (shortcut.BindKeyToApplication); - } - - [Fact] - public void BindKeyToApplication_Can_Be_Set () - { - var shortcut = new Shortcut (); - - shortcut.BindKeyToApplication = true; - - Assert.True (shortcut.BindKeyToApplication); - } - - [Fact] - public void BindKeyToApplication_Changing_Adjusts_KeyBindings () - { - var shortcut = new Shortcut (); - - shortcut.Key = Key.A; - Assert.True (shortcut.HotKeyBindings.TryGet (Key.A, out _)); - - shortcut.BindKeyToApplication = true; - Assert.False (shortcut.HotKeyBindings.TryGet (Key.A, out _)); - Assert.True (Application.KeyBindings.TryGet (Key.A, out _)); - - shortcut.BindKeyToApplication = false; - Assert.True (shortcut.HotKeyBindings.TryGet (Key.A, out _)); - Assert.False (Application.KeyBindings.TryGet (Key.A, out _)); - } - [Theory] [InlineData (Orientation.Horizontal)] [InlineData (Orientation.Vertical)] From 0227174473d4c642d21b7f188012762308adf550 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 15 Nov 2025 16:17:54 -0700 Subject: [PATCH 07/17] 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. --- .../UICatalog/Scenarios/AllViewsTester.cs | 2 +- .../UICatalog/Scenarios/ComboBoxIteration.cs | 4 +- .../UICatalog/Scenarios/DynamicMenuBar.cs | 24 ++--- .../UICatalog/Scenarios/DynamicStatusBar.cs | 29 ++++-- .../UICatalog/Scenarios/ListsAndCombos.cs | 2 +- Examples/UICatalog/Scenarios/SpinnerStyles.cs | 2 +- Examples/UICatalog/UICatalogTop.cs | 10 +- .../CollectionNavigatorBase.cs | 76 ++++++++------- .../ICollectionNavigator.cs | 8 +- Terminal.Gui/Views/ComboBox.cs | 25 +++-- Terminal.Gui/Views/ListView.cs | 94 ++++++++++--------- Terminal.Gui/Views/ListViewEventArgs.cs | 9 +- Terminal.Gui/Views/TableView/TableView.cs | 6 +- .../UICatalog/ScenarioTests.cs | 4 +- Tests/UnitTests/Views/ListViewTests.cs | 8 +- .../Text/CollectionNavigatorTests.cs | 30 +++--- .../Views/IListDataSourceTests.cs | 4 +- .../Views/ListViewTests.cs | 51 +++++----- 18 files changed, 211 insertions(+), 177 deletions(-) diff --git a/Examples/UICatalog/Scenarios/AllViewsTester.cs b/Examples/UICatalog/Scenarios/AllViewsTester.cs index c3695121e8..67ac13d569 100644 --- a/Examples/UICatalog/Scenarios/AllViewsTester.cs +++ b/Examples/UICatalog/Scenarios/AllViewsTester.cs @@ -65,7 +65,7 @@ public override void Main () // Dispose existing current View, if any DisposeCurrentView (); - CreateCurrentView (_viewClasses.Values.ToArray () [_classListView.SelectedItem]); + CreateCurrentView (_viewClasses.Values.ToArray () [_classListView.SelectedItem.Value]); // Force ViewToEdit to be the view and not a subview if (_adornmentsEditor is { }) diff --git a/Examples/UICatalog/Scenarios/ComboBoxIteration.cs b/Examples/UICatalog/Scenarios/ComboBoxIteration.cs index 9440f37f3f..0039263966 100644 --- a/Examples/UICatalog/Scenarios/ComboBoxIteration.cs +++ b/Examples/UICatalog/Scenarios/ComboBoxIteration.cs @@ -42,8 +42,8 @@ public override void Main () listview.SelectedItemChanged += (s, e) => { - lbListView.Text = items [e.Item]; - comboBox.SelectedItem = e.Item; + lbListView.Text = items [e.Item!.Value]; + comboBox.SelectedItem = e.Item.Value; }; comboBox.SelectedItemChanged += (sender, text) => diff --git a/Examples/UICatalog/Scenarios/DynamicMenuBar.cs b/Examples/UICatalog/Scenarios/DynamicMenuBar.cs index 2687b4f6cb..28a13a9169 100644 --- a/Examples/UICatalog/Scenarios/DynamicMenuBar.cs +++ b/Examples/UICatalog/Scenarios/DynamicMenuBar.cs @@ -712,7 +712,7 @@ public DynamicMenuBarSample () btnUp.Accepting += (s, e) => { - int i = _lstMenus.SelectedItem; + int i = _lstMenus.SelectedItem.Value; MenuItem menuItem = DataContext.Menus.Count > 0 ? DataContext.Menus [i].MenuItem : null; if (menuItem != null) @@ -734,7 +734,7 @@ public DynamicMenuBarSample () btnDown.Accepting += (s, e) => { - int i = _lstMenus.SelectedItem; + int i = _lstMenus.SelectedItem.Value; MenuItem menuItem = DataContext.Menus.Count > 0 ? DataContext.Menus [i].MenuItem : null; if (menuItem != null) @@ -836,7 +836,7 @@ public DynamicMenuBarSample () : MenuItemCheckStyle.Radio, ShortcutKey = frmMenuDetails.TextShortcutKey.Text }; - UpdateMenuItem (_currentEditMenuBarItem, menuItem, _lstMenus.SelectedItem); + UpdateMenuItem (_currentEditMenuBarItem, menuItem, _lstMenus.SelectedItem.Value); } }; @@ -885,8 +885,8 @@ public DynamicMenuBarSample () btnRemove.Accepting += (s, e) => { - MenuItem menuItem = (DataContext.Menus.Count > 0 && _lstMenus.SelectedItem > -1 - ? DataContext.Menus [_lstMenus.SelectedItem].MenuItem + MenuItem menuItem = (DataContext.Menus.Count > 0 && _lstMenus.SelectedItem is {} selectedItem + ? DataContext.Menus [selectedItem].MenuItem : _currentEditMenuBarItem); if (menuItem != null) @@ -905,9 +905,9 @@ public DynamicMenuBarSample () SelectCurrentMenuBarItem (); } - if (_lstMenus.SelectedItem > -1) + if (_lstMenus.SelectedItem is {} selected) { - DataContext.Menus?.RemoveAt (_lstMenus.SelectedItem); + DataContext.Menus?.RemoveAt (selected); } if (_lstMenus.Source.Count > 0 && _lstMenus.SelectedItem > _lstMenus.Source.Count - 1) @@ -927,7 +927,7 @@ public DynamicMenuBarSample () _lstMenus.OpenSelectedItem += (s, e) => { - _currentMenuBarItem = DataContext.Menus [e.Item].MenuItem; + _currentMenuBarItem = DataContext.Menus [e.Item.Value].MenuItem; if (!(_currentMenuBarItem is MenuBarItem)) { @@ -945,8 +945,8 @@ public DynamicMenuBarSample () _lstMenus.HasFocusChanging += (s, e) => { - MenuItem menuBarItem = _lstMenus.SelectedItem > -1 && DataContext.Menus.Count > 0 - ? DataContext.Menus [_lstMenus.SelectedItem].MenuItem + MenuItem menuBarItem = _lstMenus.SelectedItem is {} selectedItem && DataContext.Menus.Count > 0 + ? DataContext.Menus [selectedItem].MenuItem : null; SetFrameDetails (menuBarItem); }; @@ -1077,8 +1077,8 @@ void SetFrameDetails (MenuItem menuBarItem = null) if (menuBarItem == null) { - menuItem = _lstMenus.SelectedItem > -1 && DataContext.Menus.Count > 0 - ? DataContext.Menus [_lstMenus.SelectedItem].MenuItem + menuItem = _lstMenus.SelectedItem is {} selectedItem && DataContext.Menus.Count > 0 + ? DataContext.Menus [selectedItem].MenuItem : _currentEditMenuBarItem; } else diff --git a/Examples/UICatalog/Scenarios/DynamicStatusBar.cs b/Examples/UICatalog/Scenarios/DynamicStatusBar.cs index de110d0c6f..73dd3b802e 100644 --- a/Examples/UICatalog/Scenarios/DynamicStatusBar.cs +++ b/Examples/UICatalog/Scenarios/DynamicStatusBar.cs @@ -312,7 +312,12 @@ public DynamicStatusBarSample () btnUp.Accepting += (s, e) => { - int i = _lstItems.SelectedItem; + if (_lstItems.SelectedItem is null) + { + return; + } + int i = _lstItems.SelectedItem.Value; + Shortcut statusItem = DataContext.Items.Count > 0 ? DataContext.Items [i].Shortcut : null; if (statusItem != null) @@ -335,7 +340,12 @@ public DynamicStatusBarSample () btnDown.Accepting += (s, e) => { - int i = _lstItems.SelectedItem; + if (_lstItems.SelectedItem is null) + { + return; + } + int i = _lstItems.SelectedItem.Value; + Shortcut statusItem = DataContext.Items.Count > 0 ? DataContext.Items [i].Shortcut : null; if (statusItem != null) @@ -376,14 +386,17 @@ public DynamicStatusBarSample () } else if (_currentEditStatusItem != null) { - var statusItem = new DynamicStatusItem { Title = frmStatusBarDetails.TextTitle.Text, Action = frmStatusBarDetails.TextAction.Text, Shortcut = frmStatusBarDetails.TextShortcut.Text }; - UpdateStatusItem (_currentEditStatusItem, statusItem, _lstItems.SelectedItem); + + if (_lstItems.SelectedItem is { } selectedItem) + { + UpdateStatusItem (_currentEditStatusItem, statusItem, selectedItem); + } } }; @@ -420,14 +433,14 @@ public DynamicStatusBarSample () btnRemove.Accepting += (s, e) => { Shortcut statusItem = DataContext.Items.Count > 0 - ? DataContext.Items [_lstItems.SelectedItem].Shortcut + ? DataContext.Items [_lstItems.SelectedItem.Value].Shortcut : null; if (statusItem != null) { _statusBar.RemoveShortcut (_currentSelectedStatusBar); statusItem.Dispose (); - DataContext.Items.RemoveAt (_lstItems.SelectedItem); + DataContext.Items.RemoveAt (_lstItems.SelectedItem.Value); if (_lstItems.Source.Count > 0 && _lstItems.SelectedItem > _lstItems.Source.Count - 1) { @@ -442,7 +455,7 @@ public DynamicStatusBarSample () _lstItems.HasFocusChanging += (s, e) => { Shortcut statusItem = DataContext.Items.Count > 0 - ? DataContext.Items [_lstItems.SelectedItem].Shortcut + ? DataContext.Items [_lstItems.SelectedItem.Value].Shortcut : null; SetFrameDetails (statusItem); }; @@ -489,7 +502,7 @@ void SetFrameDetails (Shortcut statusItem = null) if (statusItem == null) { newStatusItem = DataContext.Items.Count > 0 - ? DataContext.Items [_lstItems.SelectedItem].Shortcut + ? DataContext.Items [_lstItems.SelectedItem.Value].Shortcut : null; } else diff --git a/Examples/UICatalog/Scenarios/ListsAndCombos.cs b/Examples/UICatalog/Scenarios/ListsAndCombos.cs index dba1adc739..775c78d245 100644 --- a/Examples/UICatalog/Scenarios/ListsAndCombos.cs +++ b/Examples/UICatalog/Scenarios/ListsAndCombos.cs @@ -50,7 +50,7 @@ public override void Main () Width = Dim.Percent (40), Source = new ListWrapper (items) }; - listview.SelectedItemChanged += (s, e) => lbListView.Text = items [listview.SelectedItem]; + listview.SelectedItemChanged += (s, e) => lbListView.Text = items [listview.SelectedItem.Value]; win.Add (lbListView, listview); //var scrollBar = new ScrollBarView (listview, true); diff --git a/Examples/UICatalog/Scenarios/SpinnerStyles.cs b/Examples/UICatalog/Scenarios/SpinnerStyles.cs index e9e923ae7d..87e2b1b3b5 100644 --- a/Examples/UICatalog/Scenarios/SpinnerStyles.cs +++ b/Examples/UICatalog/Scenarios/SpinnerStyles.cs @@ -153,7 +153,7 @@ public override void Main () else { spinner.Visible = true; - spinner.Style = (SpinnerStyle)Activator.CreateInstance (styleDict [e.Item].Value); + spinner.Style = (SpinnerStyle)Activator.CreateInstance (styleDict [e.Item.Value].Value); delayField.Text = spinner.SpinDelay.ToString (); ckbBounce.CheckedState = spinner.SpinBounce ? CheckState.Checked : CheckState.UnChecked; ckbNoSpecial.CheckedState = !spinner.HasSpecialCharacters ? CheckState.Checked : CheckState.UnChecked; diff --git a/Examples/UICatalog/UICatalogTop.cs b/Examples/UICatalog/UICatalogTop.cs index afec138a80..39e438a1f9 100644 --- a/Examples/UICatalog/UICatalogTop.cs +++ b/Examples/UICatalog/UICatalogTop.cs @@ -43,7 +43,7 @@ public UICatalogTop () Unloaded += UnloadedHandler; // Restore previous selections - _categoryList.SelectedItem = _cachedCategoryIndex; + _categoryList.SelectedItem = _cachedCategoryIndex ?? 0; _scenarioList.SelectedRow = _cachedScenarioIndex; SchemeName = CachedTopLevelScheme = SchemeManager.SchemesToSchemeName (Schemes.Base); @@ -509,7 +509,7 @@ private void ScenarioView_OpenSelectedItem (object? sender, EventArgs? e) #region Category List private readonly ListView? _categoryList; - private static int _cachedCategoryIndex; + private static int? _cachedCategoryIndex; public static ObservableCollection? CachedCategories { get; set; } private ListView CreateCategoryList () @@ -539,7 +539,11 @@ private ListView CreateCategoryList () private void CategoryView_SelectedChanged (object? sender, ListViewItemEventArgs? e) { - string item = CachedCategories! [e!.Item]; + if (e is null or { Item: null }) + { + return; + } + string item = CachedCategories! [e.Item.Value]; ObservableCollection newScenarioList; if (e.Item == 0) diff --git a/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs b/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs index 274d326223..86dca98f2b 100644 --- a/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs +++ b/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs @@ -1,6 +1,5 @@ ο»Ώ#nullable enable - namespace Terminal.Gui.Views; /// @@ -27,8 +26,13 @@ private set public int TypingDelay { get; set; } = 500; /// - public int GetNextMatchingItem (int currentIndex, char keyStruck) + public int? GetNextMatchingItem (int? currentIndex, char keyStruck) { + if (currentIndex < 0) + { + throw new ArgumentOutOfRangeException (nameof (currentIndex), @"Must be non-negative"); + } + if (!char.IsControl (keyStruck)) { // maybe user pressed 'd' and now presses 'd' again. @@ -36,7 +40,7 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) // but if we find none then we must fallback on cycling // d instead and discard the candidate state var candidateState = ""; - var elapsedTime = DateTime.Now - _lastKeystroke; + TimeSpan elapsedTime = DateTime.Now - _lastKeystroke; Logging.Debug ($"CollectionNavigator began processing '{keyStruck}', it has been {elapsedTime} since last keystroke"); @@ -51,26 +55,28 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) { // its a fresh keystroke after some time // or its first ever key press - SearchString = new string (keyStruck, 1); - Logging.Debug ($"It has been too long since last key press so beginning new search"); + SearchString = new (keyStruck, 1); + Logging.Debug ("It has been too long since last key press so beginning new search"); } - int idxCandidate = GetNextMatchingItem ( - currentIndex, - candidateState, + int? idxCandidate = GetNextMatchingItem ( + currentIndex, + candidateState, - // prefer not to move if there are multiple characters e.g. "ca" + 'r' should stay on "car" and not jump to "cart" - candidateState.Length > 1 - ); + // prefer not to move if there are multiple characters e.g. "ca" + 'r' should stay on "car" and not jump to "cart" + candidateState.Length > 1 + ); Logging.Debug ($"CollectionNavigator searching (preferring minimum movement) matched:{idxCandidate}"); - if (idxCandidate != -1) + + if (idxCandidate is { }) { // found "dd" so candidate search string is accepted _lastKeystroke = DateTime.Now; SearchString = candidateState; Logging.Debug ($"Found collection item that matched search:{idxCandidate}"); + return idxCandidate; } @@ -83,16 +89,17 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) // if a match wasn't found, the user typed a 'wrong' key in their search ("can" + 'z' // instead of "can" + 'd'). - if (SearchString.Length > 1 && idxCandidate == -1) + if (SearchString.Length > 1 && idxCandidate is null) { Logging.Debug ("CollectionNavigator ignored key and returned existing index"); + // ignore it since we're still within the typing delay // don't add it to SearchString either return currentIndex; } // if no changes to current state manifested - if (idxCandidate == currentIndex || idxCandidate == -1) + if (idxCandidate == currentIndex || idxCandidate is null) { Logging.Debug ("CollectionNavigator found no changes to current index, so clearing search"); @@ -100,37 +107,29 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) ClearSearchString (); // match on the fresh letter alone - SearchString = new string (keyStruck, 1); + SearchString = new (keyStruck, 1); idxCandidate = GetNextMatchingItem (currentIndex, SearchString); Logging.Debug ($"CollectionNavigator new SearchString {SearchString} matched index:{idxCandidate}"); - return idxCandidate == -1 ? currentIndex : idxCandidate; + return idxCandidate ?? currentIndex; } Logging.Debug ($"CollectionNavigator final answer was:{idxCandidate}"); + // Found another "d" or just leave index as it was return idxCandidate; } - Logging.Debug ("CollectionNavigator found key press was not actionable so clearing search and returning -1"); + Logging.Debug ("CollectionNavigator found key press was not actionable so clearing search and returning null"); // clear state because keypress was a control char ClearSearchString (); // control char indicates no selection - return -1; + return null; } - - - /// - /// Raised when the is changed. Useful for debugging. Raises the - /// event. - /// - /// - protected virtual void OnSearchStringChanged (KeystrokeNavigatorEventArgs e) { SearchStringChanged?.Invoke (this, e); } - /// This event is raised when is changed. Useful for debugging. public event EventHandler? SearchStringChanged; @@ -141,6 +140,13 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) /// Return the number of elements in the collection protected abstract int GetCollectionLength (); + /// + /// Raised when the is changed. Useful for debugging. Raises the + /// event. + /// + /// + protected virtual void OnSearchStringChanged (KeystrokeNavigatorEventArgs e) { SearchStringChanged?.Invoke (this, e); } + /// Gets the index of the next item in the collection that matches . /// The index in the collection to start the search from. /// The search string to use. @@ -150,17 +156,17 @@ public int GetNextMatchingItem (int currentIndex, char keyStruck) /// (the default), the next matching item will be returned, even if it is above in the /// collection. /// - /// The index of the next matching item or if no match was found. - internal int GetNextMatchingItem (int currentIndex, string search, bool minimizeMovement = false) + /// The index of the next matching item or if no match was found. + internal int? GetNextMatchingItem (int? currentIndex, string search, bool minimizeMovement = false) { if (string.IsNullOrEmpty (search)) { - return -1; + return null; } int collectionLength = GetCollectionLength (); - if (currentIndex != -1 && currentIndex < collectionLength && Matcher.IsMatch (search, ElementAt (currentIndex))) + if (currentIndex < collectionLength && Matcher.IsMatch (search, ElementAt (currentIndex.Value))) { // we are already at a match if (minimizeMovement) @@ -172,9 +178,9 @@ internal int GetNextMatchingItem (int currentIndex, string search, bool minimize for (var i = 1; i < collectionLength; i++) { //circular - int idxCandidate = (i + currentIndex) % collectionLength; + int? idxCandidate = (i + currentIndex) % collectionLength; - if (Matcher.IsMatch (search, ElementAt (idxCandidate))) + if (Matcher.IsMatch (search, ElementAt (idxCandidate!.Value))) { return idxCandidate; } @@ -194,7 +200,7 @@ internal int GetNextMatchingItem (int currentIndex, string search, bool minimize } // Nothing matches - return -1; + return null; } private void ClearSearchString () @@ -202,4 +208,4 @@ private void ClearSearchString () SearchString = ""; _lastKeystroke = DateTime.Now; } -} \ No newline at end of file +} diff --git a/Terminal.Gui/Views/CollectionNavigation/ICollectionNavigator.cs b/Terminal.Gui/Views/CollectionNavigation/ICollectionNavigator.cs index 85a68d3000..89ea00a101 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 ed87a93b41..51bde4d85f 100644 --- a/Terminal.Gui/Views/ComboBox.cs +++ b/Terminal.Gui/Views/ComboBox.cs @@ -48,7 +48,7 @@ public ComboBox () { if (e.Item >= 0 && !HideDropdownListOnClick && _searchSet.Count > 0) { - SetValue (_searchSet [e.Item]); + SetValue (_searchSet [e.Item.Value]); } }; Add (_search, _listview); @@ -113,7 +113,7 @@ public ComboBox () /// protected override bool OnSettingScheme (ValueChangingEventArgs args) { - _listview.SetScheme(args.NewValue); + _listview.SetScheme (args.NewValue); return base.OnSettingScheme (args); } @@ -460,7 +460,10 @@ private bool ExpandCollapse () private void FocusSelectedItem () { - _listview.SelectedItem = SelectedItem > -1 ? SelectedItem : 0; + if (_listview.Source?.Count > 0) + { + _listview.SelectedItem = SelectedItem > -1 ? SelectedItem : 0; + } _listview.TabStop = TabBehavior.TabStop; _listview.SetFocus (); OnExpanded (); @@ -516,9 +519,9 @@ private void HideList () _listview.TabStop = TabBehavior.TabStop; _listview.SetFocus (); - if (_listview.SelectedItem > -1) + if (_listview.SelectedItem is { }) { - SetValue (_searchSet [_listview.SelectedItem]); + SetValue (_searchSet [_listview.SelectedItem.Value]); } else { @@ -727,7 +730,7 @@ private bool SelectText () IsShow = false; _listview.TabStop = TabBehavior.NoStop; - if (_listview.Source.Count == 0 || (_searchSet?.Count ?? 0) == 0) + if (_listview.Source!.Count == 0 || (_searchSet?.Count ?? 0) == 0) { _text = ""; HideList (); @@ -736,7 +739,7 @@ private bool SelectText () return false; } - SetValue (_listview.SelectedItem > -1 ? _searchSet [_listview.SelectedItem] : _text); + SetValue (_listview.SelectedItem is { } ? _searchSet [_listview.SelectedItem.Value] : _text); _search.CursorPosition = _search.Text.GetColumns (); ShowHideList (Text); OnOpenSelectedItem (); @@ -976,7 +979,11 @@ public override bool OnSelectedChanged () { bool res = base.OnSelectedChanged (); - _highlighted = SelectedItem; + if (SelectedItem is null) + { + return res; + } + _highlighted = SelectedItem.Value; return res; } @@ -996,7 +1003,7 @@ private void SetInitialProperties (ComboBox container, bool hideDropdownListOnCl _container = container ?? throw new ArgumentNullException ( nameof (container), - "ComboBox container cannot be null." + @"ComboBox container cannot be null." ); HideDropdownListOnClick = hideDropdownListOnClick; AddCommand (Command.Up, () => _container.MoveUpList ()); diff --git a/Terminal.Gui/Views/ListView.cs b/Terminal.Gui/Views/ListView.cs index 9a464dc22b..f0e16047e3 100644 --- a/Terminal.Gui/Views/ListView.cs +++ b/Terminal.Gui/Views/ListView.cs @@ -108,7 +108,7 @@ public ListView () Command.HotKey, ctx => { - if (SelectedItem != -1) + if (SelectedItem is { }) { return !SetFocus (); } @@ -160,12 +160,8 @@ public ListView () } private bool _allowsMarking; - private bool _allowsMultipleSelection; - private int _selectedItem = -1; - private int _lastSelectedItem = -1; - private IListDataSource? _source; /// @@ -209,7 +205,7 @@ public bool AllowsMultipleSelection // Clear all selections except selected for (var i = 0; i < Source.Count; i++) { - if (Source.IsMarked (i) && i != SelectedItem) + if (Source.IsMarked (i) && SelectedItem.HasValue && i != SelectedItem.Value) { Source.SetMark (i, false); } @@ -228,18 +224,18 @@ public bool AllowsMultipleSelection /// Ensures the selected item is always visible on the screen. public void EnsureSelectedItemVisible () { - if (SelectedItem == -1) + if (SelectedItem is null) { return; } if (SelectedItem < Viewport.Y) { - Viewport = Viewport with { Y = SelectedItem }; + Viewport = Viewport with { Y = SelectedItem.Value }; } else if (Viewport.Height > 0 && SelectedItem >= Viewport.Y + Viewport.Height) { - Viewport = Viewport with { Y = SelectedItem - Viewport.Height + 1 }; + Viewport = Viewport with { Y = SelectedItem.Value - Viewport.Height + 1 }; } } @@ -301,15 +297,15 @@ public bool MarkAll (bool mark) /// if the was marked. public bool MarkUnmarkSelectedItem () { - if (Source is null || !UnmarkAllButSelected ()) + if (Source is null || SelectedItem is null || !UnmarkAllButSelected ()) { return false; } - Source.SetMark (SelectedItem, !Source.IsMarked (SelectedItem)); + Source.SetMark (SelectedItem.Value, !Source.IsMarked (SelectedItem.Value)); SetNeedsDraw (); - return Source.IsMarked (SelectedItem); + return Source.IsMarked (SelectedItem.Value); // BUGBUG: Shouldn't this return Source.IsMarked (SelectedItem) } @@ -323,16 +319,15 @@ 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 (SelectedItem >= Source.Count) + if (SelectedItem is null || SelectedItem >= Source.Count) { - // If for some reason we are currently outside the - // valid values range, we should select the bottommost valid value. + // If SelectedItem is null or for some reason we are currently outside the + // valid values range, we should select the first or bottommost valid value. // This can occur if the backing data source changes. - SelectedItem = Source.Count - 1; + SelectedItem = SelectedItem is null ? 0 : Source.Count - 1; } else if (SelectedItem + 1 < Source.Count) { @@ -345,7 +340,7 @@ public virtual bool MoveDown () } else if (SelectedItem < Viewport.Y) { - Viewport = Viewport with { Y = SelectedItem }; + Viewport = Viewport with { Y = SelectedItem.Value }; } } else if (SelectedItem >= Viewport.Y + Viewport.Height) @@ -369,8 +364,8 @@ public virtual bool MoveEnd () Viewport = Viewport with { Y = SelectedItem < Viewport.Height - 1 - ? Math.Max (Viewport.Height - SelectedItem + 1, 0) - : Math.Max (SelectedItem - Viewport.Height + 1, 0) + ? Math.Max (Viewport.Height - SelectedItem.Value + 1, 0) + : Math.Max (SelectedItem.Value - Viewport.Height + 1, 0) }; } } @@ -385,7 +380,7 @@ public virtual bool MoveHome () if (SelectedItem != 0) { SelectedItem = 0; - Viewport = Viewport with { Y = SelectedItem }; + Viewport = Viewport with { Y = SelectedItem.Value }; } return true; @@ -398,12 +393,12 @@ public virtual bool MoveHome () /// public virtual bool MovePageDown () { - if (Source is null) + if (Source is null || Source.Count == 0) { - return true; + return false; } - int n = SelectedItem + Viewport.Height; + int n = (SelectedItem ?? 0) + Viewport.Height; if (n >= Source.Count) { @@ -416,7 +411,7 @@ public virtual bool MovePageDown () if (Source.Count >= Viewport.Height) { - Viewport = Viewport with { Y = SelectedItem }; + Viewport = Viewport with { Y = SelectedItem.Value }; } else { @@ -431,17 +426,22 @@ public virtual bool MovePageDown () /// public virtual bool MovePageUp () { - int n = SelectedItem - Viewport.Height; + if (Source is null || Source.Count == 0) + { + return false; + } + + int n = (SelectedItem ?? 0) - Viewport.Height; if (n < 0) { n = 0; } - if (n != SelectedItem) + if (n != SelectedItem && n < Source?.Count) { SelectedItem = n; - Viewport = Viewport with { Y = SelectedItem }; + Viewport = Viewport with { Y = SelectedItem.Value }; } return true; @@ -453,13 +453,12 @@ 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 (SelectedItem >= Source.Count) + if (SelectedItem is null || SelectedItem >= Source.Count) { - // If for some reason we are currently outside the + // If SelectedItem is null or for some reason we are currently outside the // valid values range, we should select the bottommost valid value. // This can occur if the backing data source changes. SelectedItem = Source.Count - 1; @@ -475,16 +474,16 @@ public virtual bool MoveUp () if (SelectedItem < Viewport.Y) { - Viewport = Viewport with { Y = SelectedItem }; + Viewport = Viewport with { Y = SelectedItem.Value }; } else if (SelectedItem > Viewport.Y + Viewport.Height) { - Viewport = Viewport with { Y = SelectedItem - Viewport.Height + 1 }; + Viewport = Viewport with { Y = SelectedItem.Value - Viewport.Height + 1 }; } } else if (SelectedItem < Viewport.Y) { - Viewport = Viewport with { Y = SelectedItem }; + Viewport = Viewport with { Y = SelectedItem.Value }; } return true; @@ -494,14 +493,14 @@ public virtual bool MoveUp () /// if the event was fired. public bool OnOpenSelectedItem () { - if (Source is null || Source.Count <= SelectedItem || SelectedItem < 0 || OpenSelectedItem is null) + if (Source is null || SelectedItem is null || Source.Count <= SelectedItem || SelectedItem < 0 || OpenSelectedItem is null) { return false; } - object? value = Source.ToList () [SelectedItem]; + object? value = Source.ToList () [SelectedItem.Value]; - OpenSelectedItem?.Invoke (this, new (SelectedItem, value)); + OpenSelectedItem?.Invoke (this, new (SelectedItem.Value, value!)); // BUGBUG: this should not blindly return true. return true; @@ -529,19 +528,22 @@ public void ResumeSuspendCollectionChangedEvent () /// 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. - public int SelectedItem + /// The selected item or null if no item is selected. + public int? SelectedItem { get => _selectedItem; set { - if (Source is null || Source.Count == 0) + if (Source is null) { return; } - if (value < -1 || value >= Source.Count) + if (value.HasValue && (value < 0 || value >= Source.Count)) { throw new ArgumentException ("value"); } @@ -559,7 +561,7 @@ public virtual bool OnSelectedChanged () { if (SelectedItem != _lastSelectedItem) { - object? value = SelectedItem >= 0 && Source?.Count > 0 ? Source.ToList () [SelectedItem] : null; + object? value = SelectedItem.HasValue && Source?.Count > 0 ? Source.ToList () [SelectedItem.Value] : null; SelectedItemChanged?.Invoke (this, new (SelectedItem, value)); _lastSelectedItem = SelectedItem; EnsureSelectedItemVisible (); @@ -642,8 +644,8 @@ public IListDataSource? Source KeystrokeNavigator.Collection = _source?.ToList (); } - SelectedItem = -1; - _lastSelectedItem = -1; + SelectedItem = null; + _lastSelectedItem = null; SetNeedsDraw (); } } @@ -810,7 +812,7 @@ protected override bool OnKeyDown (Key key) // Enable user to find & select an item by typing text if (KeystrokeNavigator.Matcher.IsCompatibleKey (key)) { - int? newItem = KeystrokeNavigator?.GetNextMatchingItem (SelectedItem, (char)key); + int? newItem = KeystrokeNavigator?.GetNextMatchingItem (SelectedItem ?? null, (char)key); if (newItem is { } && newItem != -1) { @@ -913,7 +915,7 @@ private void Source_CollectionChanged (object? sender, NotifyCollectionChangedEv { SetContentSize (new Size (Source?.Length ?? Viewport.Width, Source?.Count ?? Viewport.Width)); - if (Source is { Count: > 0 } && SelectedItem > Source.Count - 1) + if (Source is { Count: > 0 } && SelectedItem.HasValue && SelectedItem > Source.Count - 1) { SelectedItem = Source.Count - 1; } diff --git a/Terminal.Gui/Views/ListViewEventArgs.cs b/Terminal.Gui/Views/ListViewEventArgs.cs index fe83de5808..70bdc64852 100644 --- a/Terminal.Gui/Views/ListViewEventArgs.cs +++ b/Terminal.Gui/Views/ListViewEventArgs.cs @@ -1,4 +1,5 @@ -ο»Ώnamespace Terminal.Gui.Views; +ο»Ώ#nullable enable +namespace Terminal.Gui.Views; /// for events. public class ListViewItemEventArgs : EventArgs @@ -6,17 +7,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/TableView/TableView.cs b/Terminal.Gui/Views/TableView/TableView.cs index 272e7495d3..c977731aaf 100644 --- a/Terminal.Gui/Views/TableView/TableView.cs +++ b/Terminal.Gui/Views/TableView/TableView.cs @@ -1606,11 +1606,11 @@ private bool CycleToNextTableEntryBeginningWith (Key key) return false; } - int match = CollectionNavigator.GetNextMatchingItem (row, (char)key); + int? match = CollectionNavigator.GetNextMatchingItem (row, (char)key); - if (match != -1) + if (match != null) { - SelectedRow = match; + SelectedRow = match.Value; EnsureValidSelection (); EnsureSelectedCellIsVisible (); SetNeedsDraw (); diff --git a/Tests/IntegrationTests/UICatalog/ScenarioTests.cs b/Tests/IntegrationTests/UICatalog/ScenarioTests.cs index a7943d4e55..61bb183c0c 100644 --- a/Tests/IntegrationTests/UICatalog/ScenarioTests.cs +++ b/Tests/IntegrationTests/UICatalog/ScenarioTests.cs @@ -338,7 +338,7 @@ public void Run_All_Views_Tester_Scenario () hostPane.FillRect (hostPane.Viewport); } - curView = CreateClass (viewClasses.Values.ToArray () [classListView.SelectedItem]); + curView = CreateClass (viewClasses.Values.ToArray () [classListView.SelectedItem!.Value]); }; xOptionSelector.ValueChanged += (_, _) => DimPosChanged (curView); @@ -425,7 +425,7 @@ void OnApplicationOnIteration (object? s, IterationEventArgs a) { Assert.Equal ( curView.GetType ().Name, - viewClasses.Values.ToArray () [classListView.SelectedItem].Name); + viewClasses.Values.ToArray () [classListView.SelectedItem!.Value].Name); } } else diff --git a/Tests/UnitTests/Views/ListViewTests.cs b/Tests/UnitTests/Views/ListViewTests.cs index c4aca02012..0b2660c9a9 100644 --- a/Tests/UnitTests/Views/ListViewTests.cs +++ b/Tests/UnitTests/Views/ListViewTests.cs @@ -25,7 +25,7 @@ public void Clicking_On_Border_Is_Ignored () AutoInitShutdownAttribute.RunIteration (); Assert.Equal (new (1), lv.Border!.Thickness); - Assert.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); Assert.Equal ("", lv.Text); DriverAssert.AssertDriverContentsWithFrameAre ( @@ -39,7 +39,7 @@ public void Clicking_On_Border_Is_Ignored () Application.RaiseMouseEvent (new () { ScreenPosition = new (0, 0), Flags = MouseFlags.Button1Clicked }); Assert.Equal ("", selected); - Assert.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); Application.RaiseMouseEvent ( new () @@ -95,7 +95,7 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () Application.Driver!.SetScreenSize (12, 12); AutoInitShutdownAttribute.RunIteration (); - Assert.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); DriverAssert.AssertDriverContentsWithFrameAre ( @" @@ -116,7 +116,7 @@ public void Ensures_Visibility_SelectedItem_On_MoveDown_And_MoveUp () Assert.True (lv.ScrollVertical (10)); AutoInitShutdownAttribute.RunIteration (); - Assert.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); DriverAssert.AssertDriverContentsWithFrameAre ( @" diff --git a/Tests/UnitTestsParallelizable/Text/CollectionNavigatorTests.cs b/Tests/UnitTestsParallelizable/Text/CollectionNavigatorTests.cs index c3fce7af48..5d3923d50d 100644 --- a/Tests/UnitTestsParallelizable/Text/CollectionNavigatorTests.cs +++ b/Tests/UnitTestsParallelizable/Text/CollectionNavigatorTests.cs @@ -42,7 +42,7 @@ public void Cycling () // cycling with 'a' n = new CollectionNavigator (simpleStrings); - Assert.Equal (0, n.GetNextMatchingItem (-1, 'a')); + Assert.Equal (0, n.GetNextMatchingItem (null, 'a')); Assert.Equal (1, n.GetNextMatchingItem (0, 'a')); // if 4 (candle) is selected it should loop back to apricot @@ -53,7 +53,7 @@ public void Cycling () public void Delay () { var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); // No delay @@ -96,7 +96,7 @@ public void FullText () var strings = new [] { "apricot", "arm", "ta", "target", "text", "egg", "candle" }; var n = new CollectionNavigator (strings); - var current = 0; + int? current = 0; Assert.Equal (strings.IndexOf ("ta"), current = n.GetNextMatchingItem (current, 't')); // should match "te" in "text" @@ -137,7 +137,7 @@ public void IsCompatibleKey_Does_Not_Allow_Alt_And_Ctrl_Keys (KeyCode keyCode, b public void MinimizeMovement_False_ShouldMoveIfMultipleMatches () { var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot", "c", "car", "cart" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, "$$")); Assert.Equal (strings.IndexOf ("$100.00"), current = n.GetNextMatchingItem (current, "$")); @@ -166,14 +166,14 @@ public void MinimizeMovement_False_ShouldMoveIfMultipleMatches () Assert.Equal (strings.IndexOf ("car"), current = n.GetNextMatchingItem (current, "car")); Assert.Equal (strings.IndexOf ("cart"), current = n.GetNextMatchingItem (current, "car")); - Assert.Equal (-1, current = n.GetNextMatchingItem (current, "x")); + Assert.Null (n.GetNextMatchingItem (current, "x")); } [Fact] public void MinimizeMovement_True_ShouldStayOnCurrentIfMultipleMatches () { var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot", "c", "car", "cart" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, "$$", true)); Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, "$", true)); @@ -185,14 +185,14 @@ public void MinimizeMovement_True_ShouldStayOnCurrentIfMultipleMatches () Assert.Equal (strings.IndexOf ("car"), current = n.GetNextMatchingItem (current, "car", true)); Assert.Equal (strings.IndexOf ("car"), current = n.GetNextMatchingItem (current, "car", true)); - Assert.Equal (-1, current = n.GetNextMatchingItem (current, "x", true)); + Assert.Null (n.GetNextMatchingItem (current, "x", true)); } [Fact] public void MutliKeySearchPlusWrongKeyStays () { var strings = new [] { "a", "c", "can", "candle", "candy", "yellow", "zebra" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); // https://github.com/gui-cs/Terminal.Gui/pull/2132#issuecomment-1298425573 @@ -240,20 +240,20 @@ public void OutOfBoundsShouldBeIgnored () } [Fact] - public void ShouldAcceptNegativeOne () + public void ShouldAcceptNull () { var n = new CollectionNavigator (simpleStrings); - // Expect that index of -1 (i.e. no selection) should work correctly + // Expect that index of null (i.e. no selection) should work correctly // and select the first entry of the letter 'b' - Assert.Equal (2, n.GetNextMatchingItem (-1, 'b')); + Assert.Equal (2, n.GetNextMatchingItem (null, 'b')); } [Fact] public void Symbols () { var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); Assert.Equal (strings.IndexOf ("apricot"), current = n.GetNextMatchingItem (current, 'a')); Assert.Equal ("a", n.SearchString); @@ -293,7 +293,7 @@ public void Unicode () var strings = new [] { "apricot", "arm", "ta", "δΈ—δΈ™δΈšδΈž", "δΈ—δΈ™δΈ›", "text", "egg", "candle" }; var n = new CollectionNavigator (strings); - var current = 0; + int? current = 0; Assert.Equal (strings.IndexOf ("δΈ—δΈ™δΈšδΈž"), current = n.GetNextMatchingItem (current, 'δΈ—')); // δΈ—δΈ™δΈšδΈž is as good a match as δΈ—δΈ™δΈ› @@ -319,7 +319,7 @@ public void Unicode () public void Word () { var strings = new [] { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); Assert.Equal (strings.IndexOf ("bat"), current = n.GetNextMatchingItem (current, 'b')); // match bat Assert.Equal (strings.IndexOf ("bat"), current = n.GetNextMatchingItem (current, 'a')); // match bat @@ -344,7 +344,7 @@ public void Word () public void CustomMatcher_NeverMatches () { var strings = new [] { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; - var current = 0; + int? current = 0; var n = new CollectionNavigator (strings); var matchNone = new Mock (); diff --git a/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs b/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs index 10f9498f11..f4e3cb0d24 100644 --- a/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs +++ b/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs @@ -137,8 +137,8 @@ public void AddItem (string item) [Fact] public void ListWrapper_Render_NullItem_RendersEmpty () { - ObservableCollection source = [null, "Item2"]; - ListWrapper wrapper = new (source); + ObservableCollection source = [null, "Item2"]; + ListWrapper wrapper = new (source); var listView = new ListView { Width = 20, Height = 2 }; listView.BeginInit (); listView.EndInit (); diff --git a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs index a068362b9c..bb9c1c35e1 100644 --- a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs @@ -1,4 +1,5 @@ -ο»Ώusing System.Collections; +ο»Ώ#nullable enable +using System.Collections; using System.Collections.ObjectModel; using System.Collections.Specialized; using Moq; @@ -10,14 +11,14 @@ public class ListViewTests [Fact] public void CollectionNavigatorMatcher_KeybindingsOverrideNavigator () { - ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; + 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.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); // Keys should be consumed to move down the navigation i.e. to apricot Assert.True (lv.NewKeyDownEvent (Key.B)); @@ -34,14 +35,14 @@ public void CollectionNavigatorMatcher_KeybindingsOverrideNavigator () [Fact] public void ListView_CollectionNavigatorMatcher_KeybindingsOverrideNavigator () { - ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; + 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.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); // Keys should be consumed to move down the navigation i.e. to apricot Assert.True (lv.NewKeyDownEvent (Key.B)); @@ -58,7 +59,7 @@ public void ListView_CollectionNavigatorMatcher_KeybindingsOverrideNavigator () [Fact] public void ListViewCollectionNavigatorMatcher_DefaultBehaviour () { - ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; + ObservableCollection source = ["apricot", "arm", "bat", "batman", "bates hotel", "candle"]; var lv = new ListView { Source = new ListWrapper (source) }; // Keys are consumed during navigation @@ -66,13 +67,13 @@ public void ListViewCollectionNavigatorMatcher_DefaultBehaviour () 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" }; + ObservableCollection source = ["apricot", "arm", "bat", "batman", "bates hotel", "candle"]; var lv = new ListView { Source = new ListWrapper (source) }; Mock matchNone = new (); @@ -94,7 +95,7 @@ public void ListViewCollectionNavigatorMatcher_IgnoreKeys () [Fact] public void ListViewCollectionNavigatorMatcher_OverrideMatching () { - ObservableCollection source = new () { "apricot", "arm", "bat", "batman", "bates hotel", "candle" }; + ObservableCollection source = ["apricot", "arm", "bat", "batman", "bates hotel", "candle"]; var lv = new ListView { Source = new ListWrapper (source) }; Mock matchNone = new (); @@ -116,7 +117,7 @@ public void ListViewCollectionNavigatorMatcher_OverrideMatching () Assert.True (lv.NewKeyDownEvent (Key.T)); Assert.Equal (5, lv.SelectedItem); - Assert.Equal ("candle", (string)lv.Source.ToList () [lv.SelectedItem]); + Assert.Equal ("candle", (string)lv.Source.ToList () [lv.SelectedItem!.Value]!); } #region ListView Tests (from ListViewTests.cs - parallelizable) @@ -127,28 +128,28 @@ public void Constructors_Defaults () var lv = new ListView (); Assert.Null (lv.Source); Assert.True (lv.CanFocus); - Assert.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); Assert.False (lv.AllowsMultipleSelection); lv = new () { Source = new ListWrapper (["One", "Two", "Three"]) }; Assert.NotNull (lv.Source); - Assert.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); lv = new () { Source = new NewListDataSource () }; Assert.NotNull (lv.Source); - Assert.Equal (-1, lv.SelectedItem); + Assert.Null (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.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.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); Assert.Equal (new (0, 1, 10, 20), lv.Frame); } @@ -195,7 +196,7 @@ public void KeyBindings_Command () var lv = new ListView { Height = 2, AllowsMarking = true, Source = new ListWrapper (source) }; lv.BeginInit (); lv.EndInit (); - Assert.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); Assert.True (lv.NewKeyDownEvent (Key.CursorDown)); Assert.Equal (0, lv.SelectedItem); Assert.True (lv.NewKeyDownEvent (Key.CursorUp)); @@ -206,9 +207,9 @@ public void KeyBindings_Command () Assert.True (lv.NewKeyDownEvent (Key.PageUp)); Assert.Equal (0, lv.SelectedItem); Assert.Equal (0, lv.TopItem); - Assert.False (lv.Source.IsMarked (lv.SelectedItem)); + Assert.False (lv.Source.IsMarked (lv.SelectedItem!.Value)); Assert.True (lv.NewKeyDownEvent (Key.Space)); - Assert.True (lv.Source.IsMarked (lv.SelectedItem)); + Assert.True (lv.Source.IsMarked (lv.SelectedItem!.Value)); var opened = false; lv.OpenSelectedItem += (s, _) => opened = true; Assert.True (lv.NewKeyDownEvent (Key.Enter)); @@ -243,7 +244,7 @@ public void HotKey_Command_Does_Not_Accept () return; - void OnAccepted (object sender, CommandEventArgs e) { accepted = true; } + void OnAccepted (object? sender, CommandEventArgs e) { accepted = true; } } [Fact] @@ -320,7 +321,7 @@ public void ListViewProcessKeyReturnValue_WithMultipleCommands () Assert.NotNull (lv.Source); // first item should be deselected by default - Assert.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); // bind shift down to move down twice in control lv.KeyBindings.Add (Key.CursorDown.WithShift, Command.Down, Command.Down); @@ -329,7 +330,7 @@ public void ListViewProcessKeyReturnValue_WithMultipleCommands () 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' + // After moving down twice from null we should be at 'Two' Assert.Equal (1, lv.SelectedItem); // clear the items @@ -349,7 +350,7 @@ public void AllowsMarking_True_SpaceWithShift_SelectsThenDown_SingleSelection () Assert.NotNull (lv.Source); // first item should be deselected by default - Assert.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); // nothing is ticked Assert.False (lv.Source.IsMarked (0)); @@ -410,7 +411,7 @@ public void AllowsMarking_True_SpaceWithShift_SelectsThenDown_MultipleSelection Assert.NotNull (lv.Source); // first item should be deselected by default - Assert.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); // nothing is ticked Assert.False (lv.Source.IsMarked (0)); @@ -497,9 +498,9 @@ public void OnEnter_Does_Not_Throw_Exception () public void SelectedItem_Get_Set () { var lv = new ListView { Source = new ListWrapper (["One", "Two", "Three"]) }; - Assert.Equal (-1, lv.SelectedItem); + Assert.Null (lv.SelectedItem); Assert.Throws (() => lv.SelectedItem = 3); - Exception exception = Record.Exception (() => lv.SelectedItem = -1); + Exception exception = Record.Exception (() => lv.SelectedItem = null); Assert.Null (exception); } From 6cb544aafe6c4721ad7dfb740e45fed7670bd125 Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 19 Nov 2025 17:34:12 -0500 Subject: [PATCH 08/17] on` functionality has been deprecated, refactored, or removed from the `Shortcut` class. --- Tests/UnitTests/Views/ShortcutTests.cs | 38 -------------------------- 1 file changed, 38 deletions(-) diff --git a/Tests/UnitTests/Views/ShortcutTests.cs b/Tests/UnitTests/Views/ShortcutTests.cs index eda014d06d..79da2dbc6c 100644 --- a/Tests/UnitTests/Views/ShortcutTests.cs +++ b/Tests/UnitTests/Views/ShortcutTests.cs @@ -478,42 +478,4 @@ public void Scheme_SetScheme_Does_Not_Fault_3664 () Application.Current.Dispose (); Application.ResetState (); } - - - // Test Key gets bound correctly - [Fact] - public void BindKeyToApplication_Defaults_To_HotKey () - { - var shortcut = new Shortcut (); - - Assert.False (shortcut.BindKeyToApplication); - } - - [Fact] - public void BindKeyToApplication_Can_Be_Set () - { - var shortcut = new Shortcut (); - - shortcut.BindKeyToApplication = true; - - Assert.True (shortcut.BindKeyToApplication); - } - - [Fact] - public void BindKeyToApplication_Changing_Adjusts_KeyBindings () - { - var shortcut = new Shortcut (); - - shortcut.Key = Key.A; - Assert.True (shortcut.HotKeyBindings.TryGet (Key.A, out _)); - - shortcut.BindKeyToApplication = true; - Assert.False (shortcut.HotKeyBindings.TryGet (Key.A, out _)); - Assert.True (Application.KeyBindings.TryGet (Key.A, out _)); - - shortcut.BindKeyToApplication = false; - Assert.True (shortcut.HotKeyBindings.TryGet (Key.A, out _)); - Assert.False (Application.KeyBindings.TryGet (Key.A, out _)); - } - } From f3111c7ead0db9eb0088ade5444f052dae4e9599 Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 19 Nov 2025 17:34:50 -0500 Subject: [PATCH 09/17] 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. --- .config/dotnet-tools.json | 5 ++ Scripts/Run-LocalCoverage.ps1 | 2 +- Terminal.Gui/Views/Toplevel.cs | 2 +- .../GuiTestContext.ContextMenu.cs | 2 +- .../GuiTestContext.ViewBase.cs | 4 +- .../SetupFakeApplicationAttribute.cs | 5 +- .../UnitTests.Parallelizable.csproj | 8 ++- .../Views/IListDataSourceTests.cs | 6 +- .../Views/ListViewTests.cs | 19 +++--- 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 +++--- 17 files changed, 255 insertions(+), 65 deletions(-) create mode 100644 .config/dotnet-tools.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000000..b0e38abdac --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "isRoot": true, + "tools": {} +} \ No newline at end of file diff --git a/Scripts/Run-LocalCoverage.ps1 b/Scripts/Run-LocalCoverage.ps1 index 312b229a96..32b88053d6 100644 --- a/Scripts/Run-LocalCoverage.ps1 +++ b/Scripts/Run-LocalCoverage.ps1 @@ -27,7 +27,7 @@ dotnet test Tests/UnitTests ` --verbosity minimal ` --collect:"XPlat Code Coverage" ` --settings Tests/UnitTests/runsettings.coverage.xml ` - --blame-hang-timeout 10s + --blame-hang-timeout 60s # ------------------------------------------------------------ # 4. Run UNIT TESTS (parallel) diff --git a/Terminal.Gui/Views/Toplevel.cs b/Terminal.Gui/Views/Toplevel.cs index 9f3854f7e1..fedc501f43 100644 --- a/Terminal.Gui/Views/Toplevel.cs +++ b/Terminal.Gui/Views/Toplevel.cs @@ -82,7 +82,7 @@ public Toplevel () // TODO: IRunnable: Re-implement as a property on IRunnable /// Gets or sets whether the main loop for this is running or not. - /// Setting this property directly is discouraged. Use instead. + /// Setting this property directly is discouraged. Use instead. public bool Running { get; set; } // TODO: IRunnable: Re-implement in IRunnable diff --git a/Tests/TerminalGuiFluentTesting/GuiTestContext.ContextMenu.cs b/Tests/TerminalGuiFluentTesting/GuiTestContext.ContextMenu.cs index 359fd7a0a5..136d4d0856 100644 --- a/Tests/TerminalGuiFluentTesting/GuiTestContext.ContextMenu.cs +++ b/Tests/TerminalGuiFluentTesting/GuiTestContext.ContextMenu.cs @@ -24,7 +24,7 @@ public GuiTestContext WithContextMenu (PopoverMenu? contextMenu) { // Registering with the PopoverManager will ensure that the context menu is closed when the view is no longer focused // and the context menu is disposed when it is closed. - App.Popover?.Register (contextMenu); + App?.Popover?.Register (contextMenu); contextMenu?.MakeVisible (e.ScreenPosition); } }; diff --git a/Tests/TerminalGuiFluentTesting/GuiTestContext.ViewBase.cs b/Tests/TerminalGuiFluentTesting/GuiTestContext.ViewBase.cs index 74eafc77ee..f96505840a 100644 --- a/Tests/TerminalGuiFluentTesting/GuiTestContext.ViewBase.cs +++ b/Tests/TerminalGuiFluentTesting/GuiTestContext.ViewBase.cs @@ -28,11 +28,11 @@ public GuiTestContext Add (View v) /// /// The last view added (e.g. with ) or the root/current top. /// - public View LastView => _lastView ?? App.Current ?? throw new ("Could not determine which view to add to"); + public View LastView => _lastView ?? App?.Current ?? throw new ("Could not determine which view to add to"); private T Find (Func evaluator) where T : View { - Toplevel? t = App.Current; + Toplevel? t = App?.Current; if (t == null) { diff --git a/Tests/UnitTests/SetupFakeApplicationAttribute.cs b/Tests/UnitTests/SetupFakeApplicationAttribute.cs index 0b8633da7d..06d338436d 100644 --- a/Tests/UnitTests/SetupFakeApplicationAttribute.cs +++ b/Tests/UnitTests/SetupFakeApplicationAttribute.cs @@ -32,7 +32,10 @@ public override void After (MethodInfo methodUnderTest) _appDispose?.Dispose (); _appDispose = null; - ApplicationImpl.SetInstance (null); + + // TODO: This is troublesome; it seems to cause tests to hang when enabled, but shouldn't have any impact. + // TODO: Uncomment after investigation. + //ApplicationImpl.SetInstance (null); base.After (methodUnderTest); } diff --git a/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj b/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj index 35233cc037..5024415aaf 100644 --- a/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj +++ b/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj @@ -27,6 +27,11 @@ true + + + + + @@ -69,7 +74,4 @@ - - - \ No newline at end of file diff --git a/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs b/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs index f4e3cb0d24..29faa1aab4 100644 --- a/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs +++ b/Tests/UnitTestsParallelizable/Views/IListDataSourceTests.cs @@ -343,9 +343,6 @@ public void CustomDataSource_SuspendCollectionChanged_SuppressesEvents () public void CustomDataSource_Dispose_CleansUp () { var customSource = new TestListDataSource (); - var eventRaised = false; - - customSource.CollectionChanged += (s, e) => eventRaised = true; customSource.Dispose (); @@ -500,9 +497,8 @@ public void ListWrapper_Dispose_UnsubscribesFromCollectionChanged () { ObservableCollection source = ["Item1"]; ListWrapper wrapper = new (source); - var eventRaised = false; - wrapper.CollectionChanged += (s, e) => eventRaised = true; + wrapper.CollectionChanged += (s, e) => { }; wrapper.Dispose (); diff --git a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs index bb9c1c35e1..f66d07a5ab 100644 --- a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; using Moq; +// ReSharper disable AccessToModifiedClosure namespace UnitTests_Parallelizable.ViewsTests; @@ -269,13 +270,13 @@ public void Accept_Command_Accepts_and_Opens_Selected_Item () return; - void OpenSelectedItem (object sender, ListViewItemEventArgs e) + void OpenSelectedItem (object? sender, ListViewItemEventArgs e) { opened = true; - selectedValue = e.Value.ToString (); + selectedValue = e.Value!.ToString (); } - void Accepted (object sender, CommandEventArgs e) { accepted = true; } + void Accepted (object? sender, CommandEventArgs e) { accepted = true; } } [Fact] @@ -300,13 +301,13 @@ public void Accept_Cancel_Event_Prevents_OpenSelectedItem () return; - void OpenSelectedItem (object sender, ListViewItemEventArgs e) + void OpenSelectedItem (object? sender, ListViewItemEventArgs e) { opened = true; - selectedValue = e.Value.ToString (); + selectedValue = e.Value!.ToString (); } - void Accepted (object sender, CommandEventArgs e) + void Accepted (object? sender, CommandEventArgs e) { accepted = true; e.Handled = true; @@ -780,7 +781,7 @@ public void ListWrapper_CollectionChanged_Event_UnSubscribe_Previous_Is_Disposed Assert.Equal (0, otherActions); Assert.Empty (source1); - void Lw_CollectionChanged (object sender, NotifyCollectionChangedEventArgs e) + void Lw_CollectionChanged (object? sender, NotifyCollectionChangedEventArgs e) { if (e.Action == NotifyCollectionChangedAction.Add) { @@ -820,7 +821,7 @@ public void ListWrapper_SuspendCollectionChangedEvent_ResumeSuspendCollectionCha Assert.Equal (6, lw.Count); Assert.Equal (6, source.Count); - void Lw_CollectionChanged (object sender, NotifyCollectionChangedEventArgs e) + void Lw_CollectionChanged (object? sender, NotifyCollectionChangedEventArgs e) { if (e.Action == NotifyCollectionChangedAction.Add) { @@ -860,7 +861,7 @@ public void ListView_SuspendCollectionChangedEvent_ResumeSuspendCollectionChange Assert.Equal (6, lv.Source.Count); Assert.Equal (6, source.Count); - void Lw_CollectionChanged (object sender, NotifyCollectionChangedEventArgs e) + void Lw_CollectionChanged (object? sender, NotifyCollectionChangedEventArgs e) { if (e.Action == NotifyCollectionChangedAction.Add) { diff --git a/docfx/docs/application.md b/docfx/docs/application.md index b999b1fc03..298cf6ff56 100644 --- a/docfx/docs/application.md +++ b/docfx/docs/application.md @@ -8,7 +8,7 @@ Terminal.Gui v2 uses an instance-based application architecture that decouples v graph TB subgraph ViewTree["View Hierarchy (SuperView/SubView)"] direction TB - Top[Application.Current
Window] + Top[app.Current
Window] Menu[MenuBar] Status[StatusBar] Content[Content View] @@ -22,7 +22,7 @@ graph TB Content --> Button2 end - subgraph Stack["Application.SessionStack"] + subgraph Stack["app.SessionStack"] direction TB S1[Window
Currently Active] S2[Previous Toplevel
Waiting] @@ -41,7 +41,7 @@ graph TB ```mermaid sequenceDiagram - participant App as Application + participant App as IApplication participant Main as Main Window participant Dialog as Dialog @@ -68,24 +68,29 @@ sequenceDiagram ### Instance-Based vs Static -**Terminal.Gui v2** has transitioned from a static singleton pattern to an instance-based architecture: +**Terminal.Gui v2** supports both static and instance-based patterns. The static `Application` class is marked obsolete but still functional for backward compatibility. The recommended pattern is to use `Application.Create()` to get an `IApplication` instance: ```csharp -// OLD (v1 / early v2 - now obsolete): +// OLD (v1 / early v2 - still works but obsolete): Application.Init(); -Application.Top.Add(myView); -Application.Run(); +var top = new Toplevel(); +top.Add(myView); +Application.Run(top); +top.Dispose(); Application.Shutdown(); -// NEW (v2 instance-based): -var app = Application.Create (); +// NEW (v2 recommended - instance-based): +var app = Application.Create(); app.Init(); var top = new Toplevel(); top.Add(myView); app.Run(top); +top.Dispose(); app.Shutdown(); ``` +**Note:** The static `Application` class delegates to `ApplicationImpl.Instance` (a singleton). `Application.Create()` creates a **new** `ApplicationImpl` instance, enabling multiple application contexts and better testability. + ### View.App Property Every view now has an `App` property that references its application context: @@ -226,19 +231,23 @@ int sessionCount = App?.SessionStack.Count ?? 0; ## Migration from Static Application -The static `Application` class now delegates to `ApplicationImpl.Instance` and is marked obsolete: +The static `Application` class delegates to `ApplicationImpl.Instance` (a singleton) and is marked obsolete. All static methods and properties are marked with `[Obsolete]` but remain functional for backward compatibility: ```csharp -public static class Application +public static partial class Application { - [Obsolete("Use ApplicationImpl.Instance.Current or view.App?.Current")] - public static Toplevel? Current => Instance?.Current; + [Obsolete("The legacy static Application object is going away.")] + public static Toplevel? Current => ApplicationImpl.Instance.Current; - [Obsolete("Use ApplicationImpl.Instance.SessionStack or view.App?.SessionStack")] - public static ConcurrentStack SessionStack => Instance?.SessionStack ?? new(); + [Obsolete("The legacy static Application object is going away.")] + public static ConcurrentStack SessionStack => ApplicationImpl.Instance.SessionStack; + + // ... other obsolete static members } ``` +**Important:** The static `Application` class uses a singleton (`ApplicationImpl.Instance`), while `Application.Create()` creates new instances. For new code, prefer the instance-based pattern using `Application.Create()`. + ### Migration Strategies **Strategy 1: Use View.App** @@ -472,16 +481,19 @@ public class Service } ``` -### DON'T: Assume Application.Instance Exists +### DON'T: Use Static Application in New Code ```csharp -❌ AVOID: -public class Service +❌ AVOID (obsolete pattern): +public void Refresh() { - public void DoWork() - { - var app = Application.Instance; // Might be null! - } + Application.Current?.SetNeedsDraw(); // Obsolete static access +} + +βœ… PREFERRED: +public void Refresh() +{ + App?.Current?.SetNeedsDraw(); // Use View.App property } ``` diff --git a/docfx/docs/config.md b/docfx/docs/config.md index 4a549d5ce9..e260cad347 100644 --- a/docfx/docs/config.md +++ b/docfx/docs/config.md @@ -459,7 +459,8 @@ ThemeManager.ThemeChanged += (sender, e) => { // Theme has changed // Refresh all views to use new theme - Application.Current?.SetNeedsDraw(); + // From within a View, use: App?.Current?.SetNeedsDraw(); + // Or access via IApplication instance: app.Current?.SetNeedsDraw(); }; ``` diff --git a/docfx/docs/index.md b/docfx/docs/index.md index 830ec3c19d..437d34ffcf 100644 --- a/docfx/docs/index.md +++ b/docfx/docs/index.md @@ -13,10 +13,13 @@ Welcome to the Terminal.Gui documentation! This comprehensive guide covers every - [Getting Started](~/docs/getting-started.md) - Quick start guide to create your first Terminal.Gui application - [Migrating from v1 to v2](~/docs/migratingfromv1.md) - Complete guide for upgrading existing applications - [What's New in v2](~/docs/newinv2.md) - Overview of new features and improvements +- [Showcase](~/docs/showcase.md) - Showcase of TUI apps built with Terminal.Gui ## Deep Dives - [ANSI Response Parser](~/docs/ansiparser.md) - Terminal sequence parsing and state management +- [Application](~/docs/application.md) - Application lifecycle, initialization, and main loop +- [Arrangement](~/docs/arrangement.md) - View arrangement and positioning strategies - [Cancellable Work Pattern](~/docs/cancellable-work-pattern.md) - Core design pattern for extensible workflows - [Character Map Scenario](~/docs/CharacterMap.md) - Complex drawing, scrolling, and Unicode rendering example - [Command System](~/docs/command.md) - Command execution, key bindings, and the Selecting/Accepting concepts @@ -24,6 +27,7 @@ Welcome to the Terminal.Gui documentation! This comprehensive guide covers every - [Cross-Platform Driver Model](~/docs/drivers.md) - Platform abstraction and console driver architecture - [Cursor System](~/docs/cursor.md) - Modern cursor management and positioning (proposed design) - [Dim.Auto](~/docs/dimauto.md) - Automatic view sizing based on content +- [Drawing](~/docs/drawing.md) - Drawing primitives, rendering, and graphics operations - [Events](~/docs/events.md) - Event patterns and handling throughout the framework - [Keyboard Input](~/docs/keyboard.md) - Key handling, bindings, commands, and shortcuts - [Layout System](~/docs/layout.md) - View positioning, sizing, and arrangement @@ -33,7 +37,11 @@ Welcome to the Terminal.Gui documentation! This comprehensive guide covers every - [Mouse Input](~/docs/mouse.md) - Mouse event handling and interaction patterns - [Navigation](~/docs/navigation.md) - Focus management, keyboard navigation, and accessibility - [Popovers](~/docs/Popovers.md) - Drawing outside viewport boundaries for menus and popups +- [Scheme](~/docs/scheme.md) - Color schemes, styling, and visual theming - [Scrolling](~/docs/scrolling.md) - Built-in scrolling, virtual content areas, and scroll bars +- [TableView](~/docs/tableview.md) - Table view component, data binding, and column management +- [TreeView](~/docs/treeview.md) - Tree view component, hierarchical data, and node management +- [View](~/docs/View.md) - Base view class, view hierarchy, and core view functionality ## API Reference diff --git a/docfx/docs/migratingfromv1.md b/docfx/docs/migratingfromv1.md index 8a459e0a0a..d7fd2c0b02 100644 --- a/docfx/docs/migratingfromv1.md +++ b/docfx/docs/migratingfromv1.md @@ -93,6 +93,74 @@ In v1, @Terminal.Gui./Terminal.Gui.Application.Init) automatically created a top * Update any code that assumes `Application.Init` automatically created a toplevel view and set `Application.Current`. * Update any code that assumes `Application.Init` automatically disposed of the toplevel view when the application exited. +## Instance-Based Application Architecture + +See the [Application Deep Dive](application.md) for complete details on the new application architecture. + +Terminal.Gui v2 introduces an instance-based application architecture. While the static `Application` class still works (marked obsolete), the recommended pattern is to use `Application.Create()` to get an `IApplication` instance. + +### Key Changes + +- **Static Application is Obsolete**: The static `Application` class delegates to `ApplicationImpl.Instance` (a singleton) and is marked `[Obsolete]` but remains functional for backward compatibility. +- **Recommended Pattern**: Use `Application.Create()` to get a new `IApplication` instance for better testability and multiple application contexts. +- **View.App Property**: Every view has an `App` property that references its `IApplication` context, enabling views to access application services without static dependencies. + +### Migration Strategies + +**Option 1: Continue Using Static Application (Backward Compatible)** + +The static `Application` class still works, so existing v1 code can continue to work with minimal changes: + +```csharp +// v1 code (still works in v2, but obsolete) +Application.Init(); +var top = new Toplevel(); +top.Add(myView); +Application.Run(top); +top.Dispose(); +Application.Shutdown(); +``` + +**Option 2: Migrate to Instance-Based Pattern (Recommended)** + +For new code or when refactoring, use the instance-based pattern: + +```csharp +// v2 recommended pattern +var app = Application.Create(); +app.Init(); +var top = new Toplevel(); +top.Add(myView); +app.Run(top); +top.Dispose(); +app.Shutdown(); +``` + +**Option 3: Use View.App Property** + +When accessing application services from within views, use the `App` property instead of static `Application`: + +```csharp +// OLD (v1 / obsolete static): +public void Refresh() +{ + Application.Current?.SetNeedsDraw(); +} + +// NEW (v2 - use View.App): +public void Refresh() +{ + App?.Current?.SetNeedsDraw(); +} +``` + +### Benefits of Instance-Based Architecture + +- **Testability**: Views can be tested without `Application.Init()` by setting `view.App = mockApp` +- **Multiple Contexts**: Multiple `IApplication` instances can coexist +- **Clear Ownership**: Views explicitly know their application context +- **Reduced Global State**: Less reliance on static singletons + ## @Terminal.Gui.Pos and @Terminal.Gui.Dim types now adhere to standard C# idioms * In v1, the @Terminal.Gui.Pos and @Terminal.Gui.Dim types (e.g. @Terminal.Gui.Pos.PosView) were nested classes and marked @Terminal.Gui.internal. In v2, they are no longer nested, and have appropriate public APIs. diff --git a/docfx/docs/multitasking.md b/docfx/docs/multitasking.md index a4e98b8c55..1659139250 100644 --- a/docfx/docs/multitasking.md +++ b/docfx/docs/multitasking.md @@ -9,7 +9,7 @@ Terminal.Gui applications run on a single main thread with an event loop that pr Terminal.Gui follows the standard UI toolkit pattern where **all UI operations must happen on the main thread**. Attempting to modify views or their properties from background threads will result in undefined behavior and potential crashes. ### The Golden Rule -> Always use `Application.Invoke()` to update the UI from background threads. +> Always use `Application.Invoke()` (static, obsolete) or `app.Invoke()` (instance-based, recommended) to update the UI from background threads. From within a View, use `App?.Invoke()`. ## Background Operations @@ -47,6 +47,7 @@ private async void LoadDataButton_Clicked() When working with traditional threading APIs or when async/await isn't suitable: +**From within a View (recommended):** ```csharp private void StartBackgroundWork() { @@ -58,14 +59,14 @@ private void StartBackgroundWork() Thread.Sleep(50); // Simulate work // Marshal back to main thread for UI updates - Application.Invoke(() => + App?.Invoke(() => { progressBar.Fraction = i / 100f; statusLabel.Text = $"Progress: {i}%"; }); } - Application.Invoke(() => + App?.Invoke(() => { statusLabel.Text = "Complete!"; }); @@ -73,6 +74,41 @@ private void StartBackgroundWork() } ``` +**Using IApplication instance (recommended):** +```csharp +var app = Application.Create(); +app.Init(); + +private void StartBackgroundWork(IApplication app) +{ + Task.Run(() => + { + // This code runs on a background thread + for (int i = 0; i <= 100; i++) + { + Thread.Sleep(50); // Simulate work + + // Marshal back to main thread for UI updates + app.Invoke(() => + { + progressBar.Fraction = i / 100f; + statusLabel.Text = $"Progress: {i}%"; + }); + } + + app.Invoke(() => + { + statusLabel.Text = "Complete!"; + }); + }); +} +``` + +**Using static Application (obsolete but still works):** +```csharp +Application.Invoke(() => { /* ... */ }); +``` + ## Timers Use timers for periodic updates like clocks, status refreshes, or animations: @@ -89,10 +125,11 @@ public class ClockView : View Add(timeLabel); // Update every second - timerToken = Application.AddTimeout( + // Use App?.AddTimeout() when available, or Application.AddTimeout() (obsolete) + timerToken = App?.AddTimeout( TimeSpan.FromSeconds(1), UpdateTime - ); + ) ?? Application.AddTimeout(TimeSpan.FromSeconds(1), UpdateTime); } private bool UpdateTime() @@ -105,7 +142,7 @@ public class ClockView : View { if (disposing && timerToken != null) { - Application.RemoveTimeout(timerToken); + App?.RemoveTimeout(timerToken) ?? Application.RemoveTimeout(timerToken); } base.Dispose(disposing); } @@ -220,6 +257,13 @@ Task.Run(() => ### ❌ Don't: Forget to clean up timers ```csharp // Memory leak - timer keeps running after view is disposed +// From within a View: +App?.AddTimeout(TimeSpan.FromSeconds(1), UpdateStatus); + +// Or with IApplication instance: +app.AddTimeout(TimeSpan.FromSeconds(1), UpdateStatus); + +// Or static (obsolete but works): Application.AddTimeout(TimeSpan.FromSeconds(1), UpdateStatus); ``` diff --git a/docfx/docs/navigation.md b/docfx/docs/navigation.md index 7fde067950..2c5c2391af 100644 --- a/docfx/docs/navigation.md +++ b/docfx/docs/navigation.md @@ -176,25 +176,30 @@ The @Terminal.Gui.App.ApplicationNavigation.AdvanceFocus method causes the focus The implementation is simple: ```cs -return Application.Current?.AdvanceFocus (direction, behavior); +return app.Current?.AdvanceFocus (direction, behavior); ``` -This method is called from the `Command` handlers bound to the application-scoped keybindings created during `Application.Init`. It is `public` as a convenience. +This method is called from the `Command` handlers bound to the application-scoped keybindings created during `app.Init()`. It is `public` as a convenience. + +**Note:** When accessing from within a View, use `App?.Current` instead of `Application.Current` (which is obsolete). This method replaces about a dozen functions in v1 (scattered across `Application` and `Toplevel`). ### Application Navigation Examples ```csharp +var app = Application.Create(); +app.Init(); + // Listen for global focus changes -Application.Navigation.FocusedChanged += (sender, e) => +app.Navigation.FocusedChanged += (sender, e) => { - var focused = Application.Navigation.GetFocused(); + var focused = app.Navigation.GetFocused(); StatusBar.Text = $"Focused: {focused?.GetType().Name ?? "None"}"; }; // Prevent certain views from getting focus -Application.Navigation.FocusedChanging += (sender, e) => +app.Navigation.FocusedChanging += (sender, e) => { if (e.NewView is SomeRestrictedView) { diff --git a/docfx/docs/newinv2.md b/docfx/docs/newinv2.md index e416dd7547..43dec8e87d 100644 --- a/docfx/docs/newinv2.md +++ b/docfx/docs/newinv2.md @@ -15,6 +15,47 @@ Terminal.Gui v2 represents a fundamental rethinking of the library's architectur This architectural shift has resulted in the removal of thousands of lines of redundant or overly complex code from v1, replaced with cleaner, more focused implementations. +## Instance-Based Application Architecture + +See the [Application Deep Dive](application.md) for complete details on the new application architecture. + +Terminal.Gui v2 introduces an instance-based application architecture that decouples views from global application state, dramatically improving testability and enabling multiple application contexts. + +### Key Changes + +- **Instance-Based Pattern**: The recommended pattern is to use `Application.Create()` to get an `IApplication` instance, rather than using the static `Application` class (which is marked obsolete but still functional for backward compatibility). +- **View.App Property**: Every view now has an `App` property that references its `IApplication` context, enabling views to access application services without static dependencies. +- **Session Management**: Applications manage sessions through `Begin()` and `End()` methods, with a `SessionStack` tracking nested sessions and `Current` representing the active session. +- **Improved Testability**: Views can be tested in isolation by setting their `App` property to a mock `IApplication`, eliminating the need for `Application.Init()` in unit tests. + +### Example Usage + +```csharp +// Recommended v2 pattern (instance-based) +var app = Application.Create(); +app.Init(); +var top = new Toplevel { Title = "My App" }; +top.Add(myView); +app.Run(top); +top.Dispose(); +app.Shutdown(); + +// Static pattern (obsolete but still works) +Application.Init(); +var top = new Toplevel { Title = "My App" }; +top.Add(myView); +Application.Run(top); +top.Dispose(); +Application.Shutdown(); +``` + +### Benefits + +- **Testability**: Views can be tested without initializing the entire application +- **Multiple Contexts**: Multiple `IApplication` instances can coexist (useful for testing or complex scenarios) +- **Clear Ownership**: Views explicitly know their application context via the `App` property +- **Reduced Global State**: Less reliance on static singletons improves code maintainability + ## Modern Look & Feel - Technical Details ### TrueColor Support diff --git a/docfx/docs/toc.yml b/docfx/docs/toc.yml index 8acb4573f2..c251920600 100644 --- a/docfx/docs/toc.yml +++ b/docfx/docs/toc.yml @@ -2,10 +2,16 @@ href: index.md - name: Getting Started href: getting-started.md +- name: Showcase + href: showcase.md - name: What's new in v2 href: newinv2.md - name: v1 To v2 Migration href: migratingfromv1.md +- name: Lexicon & Taxonomy + href: lexicon.md +- name: Application Deep Dive + href: application.md - name: Arrangement href: arrangement.md - name: Cancellable Work Pattern @@ -24,10 +30,6 @@ href: drivers.md - name: Events Deep Dive href: events.md -- name: Lexicon & Taxonomy - href: lexicon.md -- name: Terminology Proposal - href: terminology-index.md - name: Keyboard href: keyboard.md - name: Layout Engine @@ -40,14 +42,16 @@ href: navigation.md - name: Popovers href: Popovers.md -- name: View Deep Dive - href: View.md -- name: View List - href: views.md +- name: Scheme Deep Dive + href: scheme.md - name: Scrolling href: scrolling.md - name: TableView Deep Dive href: tableview.md - name: TreeView Deep Dive href: treeview.md +- name: View Deep Dive + href: View.md +- name: View List + href: views.md From 32c2cf1771950b15bac501ebdcf6daaa6cdae4a2 Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 19 Nov 2025 17:50:57 -0500 Subject: [PATCH 10/17] 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. --- Tests/UnitTests/Views/ListViewTests.cs | 472 ----------------- .../Views/ListViewTests.cs | 493 +++++++++++++++++- 2 files changed, 492 insertions(+), 473 deletions(-) delete mode 100644 Tests/UnitTests/Views/ListViewTests.cs diff --git a/Tests/UnitTests/Views/ListViewTests.cs b/Tests/UnitTests/Views/ListViewTests.cs deleted file mode 100644 index 0b2660c9a9..0000000000 --- a/Tests/UnitTests/Views/ListViewTests.cs +++ /dev/null @@ -1,472 +0,0 @@ -ο»Ώusing System.Collections.ObjectModel; -using Xunit.Abstractions; - -namespace UnitTests.ViewsTests; - -public class ListViewTests (ITestOutputHelper output) -{ - [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.Null (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.Null (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 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.Null (lv.SelectedItem); - - DriverAssert.AssertDriverContentsWithFrameAre ( - @" -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚Line0 β”‚ -β”‚Line1 β”‚ -β”‚Line2 β”‚ -β”‚Line3 β”‚ -β”‚Line4 β”‚ -β”‚Line5 β”‚ -β”‚Line6 β”‚ -β”‚Line7 β”‚ -β”‚Line8 β”‚ -β”‚Line9 β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", - output - ); - - Assert.True (lv.ScrollVertical (10)); - AutoInitShutdownAttribute.RunIteration (); - Assert.Null (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] - [AutoInitShutdown] - public void LeftItem_TopItem_Tests () - { - 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); - 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] - [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 (); - } -} diff --git a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs index f66d07a5ab..ed840aa4f1 100644 --- a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs @@ -3,12 +3,18 @@ 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 () { @@ -871,4 +877,489 @@ void Lw_CollectionChanged (object? sender, NotifyCollectionChangedEventArgs e) } #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 (); + } } From bbf54498c63c02e25fb1e1b12e279d8305689ce8 Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 19 Nov 2025 20:12:45 -0500 Subject: [PATCH 11/17] Update Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Views/CollectionNavigation/CollectionNavigatorBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs b/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs index b4ade5aea5..146e6e9881 100644 --- a/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs +++ b/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs @@ -28,7 +28,7 @@ private set /// public int? GetNextMatchingItem (int? currentIndex, char keyStruck) { - if (currentIndex < 0) + if (currentIndex.HasValue && currentIndex < 0) { throw new ArgumentOutOfRangeException (nameof (currentIndex), @"Must be non-negative"); } From c92112983879c9318afcc1b0a3e3c23f1b307dcb Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 19 Nov 2025 20:13:10 -0500 Subject: [PATCH 12/17] Update Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Views/CollectionNavigation/CollectionNavigatorBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs b/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs index 146e6e9881..7fd71f4917 100644 --- a/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs +++ b/Terminal.Gui/Views/CollectionNavigation/CollectionNavigatorBase.cs @@ -166,7 +166,7 @@ private set int collectionLength = GetCollectionLength (); - if (currentIndex < collectionLength && Matcher.IsMatch (search, ElementAt (currentIndex.Value))) + if (currentIndex.HasValue && currentIndex < collectionLength && Matcher.IsMatch (search, ElementAt (currentIndex.Value))) { // we are already at a match if (minimizeMovement) From 2d55e56c8b73380e81cdf5e474dff2fbba49bdcb Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 19 Nov 2025 20:13:34 -0500 Subject: [PATCH 13/17] Update Examples/UICatalog/UICatalogTop.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Examples/UICatalog/UICatalogTop.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Examples/UICatalog/UICatalogTop.cs b/Examples/UICatalog/UICatalogTop.cs index 7c6e3ed6ad..63c3eb528b 100644 --- a/Examples/UICatalog/UICatalogTop.cs +++ b/Examples/UICatalog/UICatalogTop.cs @@ -43,7 +43,11 @@ public UICatalogTop () Unloaded += UnloadedHandler; // Restore previous selections - _categoryList.SelectedItem = _cachedCategoryIndex ?? 0; + if (_categoryList.Source?.Count > 0) { + _categoryList.SelectedItem = _cachedCategoryIndex ?? 0; + } else { + _categoryList.SelectedItem = null; + } _scenarioList.SelectedRow = _cachedScenarioIndex; SchemeName = CachedTopLevelScheme = SchemeManager.SchemesToSchemeName (Schemes.Base); From 9d6eb72701740d13a110826cb3c3aaecaa3b09cd Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 19 Nov 2025 20:14:09 -0500 Subject: [PATCH 14/17] Update Terminal.Gui/Views/ListWrapper.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Terminal.Gui/Views/ListWrapper.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Terminal.Gui/Views/ListWrapper.cs b/Terminal.Gui/Views/ListWrapper.cs index 1b2a8ae742..b9cbbfcc96 100644 --- a/Terminal.Gui/Views/ListWrapper.cs +++ b/Terminal.Gui/Views/ListWrapper.cs @@ -161,12 +161,9 @@ internal int StartsWith (string search) return i; } } - else if (t is string s) + else if (t is string s && s.StartsWith (search, StringComparison.InvariantCultureIgnoreCase)) { - if (s.StartsWith (search, StringComparison.InvariantCultureIgnoreCase)) - { - return i; - } + return i; } } From 5bcaa3ddf59dfda1b24bd079b4651c15b35426ce Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 19 Nov 2025 20:14:25 -0500 Subject: [PATCH 15/17] Update Terminal.Gui/Views/ListWrapper.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Terminal.Gui/Views/ListWrapper.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/Terminal.Gui/Views/ListWrapper.cs b/Terminal.Gui/Views/ListWrapper.cs index b9cbbfcc96..5f10b4e06f 100644 --- a/Terminal.Gui/Views/ListWrapper.cs +++ b/Terminal.Gui/Views/ListWrapper.cs @@ -208,14 +208,7 @@ private int GetMaxLengthItem () int l; - if (t is string u) - { - l = u.GetColumns (); - } - else - { - l = t.ToString ()!.Length; - } + l = t is string u ? u.GetColumns () : t.ToString ()!.Length; if (l > maxLength) { From 55b07abb6e65718b938f9c947fec79425ae5ffe5 Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 19 Nov 2025 20:15:26 -0500 Subject: [PATCH 16/17] 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. --- Terminal.Gui/Views/ListView.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Terminal.Gui/Views/ListView.cs b/Terminal.Gui/Views/ListView.cs index f0e16047e3..e958844bfe 100644 --- a/Terminal.Gui/Views/ListView.cs +++ b/Terminal.Gui/Views/ListView.cs @@ -306,8 +306,6 @@ public bool MarkUnmarkSelectedItem () SetNeedsDraw (); return Source.IsMarked (SelectedItem.Value); - - // BUGBUG: Shouldn't this return Source.IsMarked (SelectedItem) } /// Gets the widest item in the list. @@ -545,7 +543,7 @@ public int? SelectedItem if (value.HasValue && (value < 0 || value >= Source.Count)) { - throw new ArgumentException ("value"); + throw new ArgumentException (@"SelectedItem must be greater than 0 or less than the number of items."); } _selectedItem = value; From 6b94e7bfada8f8b542b6ca0003671b31f544dcac Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 19 Nov 2025 20:30:08 -0500 Subject: [PATCH 17/17] 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. --- .../Views/ListViewTests.cs | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs index ed840aa4f1..3634121658 100644 --- a/Tests/UnitTestsParallelizable/Views/ListViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/ListViewTests.cs @@ -1362,4 +1362,223 @@ public void RowRender_Event () 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); + } }