Skip to content

Commit a1e11ce

Browse files
authored
Ensures CollectionNavigator does not repond to alt or ctrl keys (gui-cs#5014)
* Cleans up examples. * updated docs * Update IsCompatibleKey to reject Alt/Ctrl; improve tests IsCompatibleKey now rejects keys with Alt or Ctrl modifiers, ensuring only plain character keys are accepted for search. Added tests to verify this behavior, including scenarios with AssociatedText. Refactored and reformatted tests for clarity, style compliance, and improved thread safety test code.
1 parent f9fb3fc commit a1e11ce

2 files changed

Lines changed: 114 additions & 66 deletions

File tree

Terminal.Gui/Views/CollectionNavigation/DefaultCollectionNavigatorMatcher.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
2-
3-
41
namespace Terminal.Gui.Views;
52

63
/// <summary>
@@ -14,10 +11,13 @@ internal class DefaultCollectionNavigatorMatcher : ICollectionNavigatorMatcher
1411
public StringComparison Comparer { get; set; } = StringComparison.InvariantCultureIgnoreCase;
1512

1613
/// <inheritdoc/>
17-
public virtual bool IsMatch (string search, object? value) { return value?.ToString ()?.StartsWith (search, Comparer) ?? false; }
14+
public virtual bool IsMatch (string search, object? value)
15+
{
16+
return value?.ToString ()?.StartsWith (search, Comparer) ?? false;
17+
}
1818

1919
/// <summary>
20-
/// Returns true if <paramref name="key"/> is key searchable key (e.g. letters, numbers, etc) that are valid to pass
20+
/// Returns true if <paramref name="key"/> is key searchable key (e.g. letters, numbers, etc.) that are valid to pass
2121
/// to this class for search filtering.
2222
/// </summary>
2323
/// <param name="key"></param>
@@ -26,6 +26,6 @@ public bool IsCompatibleKey (Key key)
2626
{
2727
Rune rune = key.AsRune;
2828

29-
return rune != default && !Rune.IsControl (rune);
29+
return rune != default (Rune) && !Rune.IsControl (rune) && key is { IsAlt: false, IsCtrl: false };
3030
}
3131
}

Tests/UnitTestsParallelizable/Text/CollectionNavigatorTests.cs

Lines changed: 108 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
using System.Collections;
21
using System.Collections.Concurrent;
2+
using System.Text;
33
using Moq;
44

55
namespace TextTests;
@@ -15,10 +15,6 @@ public class CollectionNavigatorTests
1515
"candle" // 4
1616
};
1717

18-
private readonly ITestOutputHelper _output;
19-
20-
public CollectionNavigatorTests (ITestOutputHelper output) { _output = output; }
21-
2218
[Fact]
2319
public void AtSymbol ()
2420
{
@@ -30,6 +26,24 @@ public void AtSymbol ()
3026
Assert.Equal (4, n.GetNextMatchingItem (3, 'b'));
3127
}
3228

29+
[Fact]
30+
public void CustomMatcher_NeverMatches ()
31+
{
32+
var strings = new [] { "apricot", "arm", "bat", "batman", "bates hotel", "candle" };
33+
int? current = 0;
34+
var n = new CollectionNavigator (strings);
35+
36+
Mock<ICollectionNavigatorMatcher> matchNone = new ();
37+
38+
matchNone.Setup (m => m.IsMatch (It.IsAny<string> (), It.IsAny<object> ())).Returns (false);
39+
40+
n.Matcher = matchNone.Object;
41+
42+
Assert.Equal (0, current = n.GetNextMatchingItem (current, 'b')); // no matches
43+
Assert.Equal (0, current = n.GetNextMatchingItem (current, 'a')); // no matches
44+
Assert.Equal (0, current = n.GetNextMatchingItem (current, 't')); // no matches
45+
}
46+
3347
[Fact]
3448
public void Cycling ()
3549
{
@@ -42,7 +56,7 @@ public void Cycling ()
4256
Assert.Equal (2, n.GetNextMatchingItem (4, 'b'));
4357

4458
// cycling with 'a'
45-
n = new (simpleStrings);
59+
n = new CollectionNavigator (simpleStrings);
4660
Assert.Equal (0, n.GetNextMatchingItem (null, 'a'));
4761
Assert.Equal (1, n.GetNextMatchingItem (0, 'a'));
4862

@@ -65,7 +79,7 @@ public void Delay ()
6579
Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, '$'));
6680
Assert.Equal ("$$", n.SearchString);
6781

68-
// Delay
82+
// Delay
6983
Thread.Sleep (n.TypingDelay + 10);
7084
Assert.Equal (strings.IndexOf ("apricot"), current = n.GetNextMatchingItem (current, 'a'));
7185
Assert.Equal ("a", n.SearchString);
@@ -134,10 +148,54 @@ public void IsCompatibleKey_Does_Not_Allow_Alt_And_Ctrl_Keys (KeyCode keyCode, b
134148
Assert.Equal (compatible, m.IsCompatibleKey (keyCode));
135149
}
136150

151+
// Copilot - Opus 4.6
152+
153+
/// <summary>
154+
/// Verifies that when AssociatedText is set (e.g. Kitty keyboard protocol),
155+
/// Alt/Ctrl keys are still rejected even though AsRune returns a valid rune.
156+
/// </summary>
157+
[Theory]
158+
[InlineData (KeyCode.A | KeyCode.AltMask, "a", false)]
159+
[InlineData (KeyCode.Z | KeyCode.AltMask, "z", false)]
160+
[InlineData (KeyCode.A | KeyCode.CtrlMask, "a", false)]
161+
[InlineData (KeyCode.Z | KeyCode.CtrlMask, "z", false)]
162+
[InlineData (KeyCode.A | KeyCode.CtrlMask | KeyCode.AltMask, "a", false)]
163+
[InlineData (KeyCode.A, "a", true)]
164+
[InlineData (KeyCode.A | KeyCode.ShiftMask, "A", true)]
165+
[InlineData (KeyCode.Space, " ", true)]
166+
public void IsCompatibleKey_WithAssociatedText_RejectsAltAndCtrl (KeyCode keyCode, string associatedText, bool expected)
167+
{
168+
DefaultCollectionNavigatorMatcher matcher = new ();
169+
Key key = new (keyCode) { AssociatedText = associatedText };
170+
171+
// Confirm the rune is valid (non-default, non-control) — this is the scenario
172+
// where the old code (checking only the rune) would have incorrectly returned true.
173+
Rune rune = key.AsRune;
174+
175+
if (!expected)
176+
{
177+
Assert.NotEqual (default (Rune), rune);
178+
Assert.False (Rune.IsControl (rune));
179+
}
180+
181+
Assert.Equal (expected, matcher.IsCompatibleKey (key));
182+
}
183+
137184
[Fact]
138185
public void MinimizeMovement_False_ShouldMoveIfMultipleMatches ()
139186
{
140-
var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot", "c", "car", "cart" };
187+
var strings = new []
188+
{
189+
"$$",
190+
"$100.00",
191+
"$101.00",
192+
"$101.10",
193+
"$200.00",
194+
"apricot",
195+
"c",
196+
"car",
197+
"cart"
198+
};
141199
int? current = 0;
142200
var n = new CollectionNavigator (strings);
143201
Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, "$$"));
@@ -173,7 +231,18 @@ public void MinimizeMovement_False_ShouldMoveIfMultipleMatches ()
173231
[Fact]
174232
public void MinimizeMovement_True_ShouldStayOnCurrentIfMultipleMatches ()
175233
{
176-
var strings = new [] { "$$", "$100.00", "$101.00", "$101.10", "$200.00", "apricot", "c", "car", "cart" };
234+
var strings = new []
235+
{
236+
"$$",
237+
"$100.00",
238+
"$101.00",
239+
"$101.10",
240+
"$200.00",
241+
"apricot",
242+
"c",
243+
"car",
244+
"cart"
245+
};
177246
int? current = 0;
178247
var n = new CollectionNavigator (strings);
179248
Assert.Equal (strings.IndexOf ("$$"), current = n.GetNextMatchingItem (current, "$$", true));
@@ -326,39 +395,11 @@ public void Word ()
326395
Assert.Equal (strings.IndexOf ("bat"), current = n.GetNextMatchingItem (current, 'a')); // match bat
327396
Assert.Equal (strings.IndexOf ("bat"), current = n.GetNextMatchingItem (current, 't')); // match bat
328397

329-
Assert.Equal (
330-
strings.IndexOf ("bates hotel"),
331-
current = n.GetNextMatchingItem (current, 'e')
332-
); // match bates hotel
398+
Assert.Equal (strings.IndexOf ("bates hotel"), current = n.GetNextMatchingItem (current, 'e')); // match bates hotel
333399

334-
Assert.Equal (
335-
strings.IndexOf ("bates hotel"),
336-
current = n.GetNextMatchingItem (current, 's')
337-
); // match bates hotel
400+
Assert.Equal (strings.IndexOf ("bates hotel"), current = n.GetNextMatchingItem (current, 's')); // match bates hotel
338401

339-
Assert.Equal (
340-
strings.IndexOf ("bates hotel"),
341-
current = n.GetNextMatchingItem (current, ' ')
342-
); // match bates hotel
343-
}
344-
345-
[Fact]
346-
public void CustomMatcher_NeverMatches ()
347-
{
348-
var strings = new [] { "apricot", "arm", "bat", "batman", "bates hotel", "candle" };
349-
int? current = 0;
350-
var n = new CollectionNavigator (strings);
351-
352-
Mock<ICollectionNavigatorMatcher> matchNone = new ();
353-
354-
matchNone.Setup (m => m.IsMatch (It.IsAny<string> (), It.IsAny<object> ()))
355-
.Returns (false);
356-
357-
n.Matcher = matchNone.Object;
358-
359-
Assert.Equal (0, current = n.GetNextMatchingItem (current, 'b')); // no matches
360-
Assert.Equal (0, current = n.GetNextMatchingItem (current, 'a')); // no matches
361-
Assert.Equal (0, current = n.GetNextMatchingItem (current, 't')); // no matches
402+
Assert.Equal (strings.IndexOf ("bates hotel"), current = n.GetNextMatchingItem (current, ' ')); // match bates hotel
362403
}
363404

364405
#region Thread Safety Tests
@@ -371,21 +412,20 @@ public void ThreadSafety_ConcurrentSearchStringAccess ()
371412
var numTasks = 20;
372413
ConcurrentBag<Exception> exceptions = new ();
373414

374-
Parallel.For (
375-
0,
415+
Parallel.For (0,
376416
numTasks,
377417
i =>
378418
{
379419
try
380420
{
381421
// Read SearchString concurrently
382-
string searchString = navigator.SearchString;
422+
_ = navigator.SearchString;
383423

384424
// Perform navigation operations concurrently
385-
int? result = navigator.GetNextMatchingItem (0, 'a');
425+
_ = navigator.GetNextMatchingItem (0, 'a');
386426

387427
// Read SearchString again
388-
searchString = navigator.SearchString;
428+
_ = navigator.SearchString;
389429
}
390430
catch (Exception ex)
391431
{
@@ -404,18 +444,17 @@ public void ThreadSafety_ConcurrentCollectionAccess ()
404444
var numTasks = 20;
405445
ConcurrentBag<Exception> exceptions = new ();
406446

407-
Parallel.For (
408-
0,
447+
Parallel.For (0,
409448
numTasks,
410449
i =>
411450
{
412451
try
413452
{
414453
// Access Collection property concurrently
415-
IList collection = navigator.Collection;
454+
_ = navigator.Collection;
416455

417456
// Perform navigation
418-
int? result = navigator.GetNextMatchingItem (0, (char)('a' + i % 3));
457+
_ = navigator.GetNextMatchingItem (0, (char)('a' + i % 3));
419458
}
420459
catch (Exception ex)
421460
{
@@ -435,8 +474,7 @@ public void ThreadSafety_ConcurrentNavigationOperations ()
435474
ConcurrentBag<int?> results = new ();
436475
ConcurrentBag<Exception> exceptions = new ();
437476

438-
Parallel.For (
439-
0,
477+
Parallel.For (0,
440478
numTasks,
441479
i =>
442480
{
@@ -475,8 +513,8 @@ public void ThreadSafety_ConcurrentCollectionModification ()
475513
{
476514
for (var j = 0; j < 100; j++)
477515
{
478-
int? result = navigator.GetNextMatchingItem (0, 'a');
479-
string searchString = navigator.SearchString;
516+
_ = navigator.GetNextMatchingItem (0, 'a');
517+
_ = navigator.SearchString;
480518
}
481519
}
482520
catch (Exception ex)
@@ -523,16 +561,27 @@ public void ThreadSafety_ConcurrentCollectionModification ()
523561
[Fact]
524562
public void ThreadSafety_ConcurrentSearchStringChanges ()
525563
{
526-
var strings = new [] { "apricot", "arm", "bat", "batman", "candle", "cat", "dog", "elephant", "fox", "goat" };
564+
var strings = new []
565+
{
566+
"apricot",
567+
"arm",
568+
"bat",
569+
"batman",
570+
"candle",
571+
"cat",
572+
"dog",
573+
"elephant",
574+
"fox",
575+
"goat"
576+
};
527577
var navigator = new CollectionNavigator (strings);
528578
var numTasks = 30;
529579
ConcurrentBag<Exception> exceptions = new ();
530580
ConcurrentBag<string> searchStrings = new ();
531581

532-
Parallel.For (
533-
0,
582+
Parallel.For (0,
534583
numTasks,
535-
i =>
584+
_ =>
536585
{
537586
try
538587
{
@@ -570,8 +619,7 @@ public void ThreadSafety_StressTest_RapidOperations ()
570619
var operationsPerTask = 1000;
571620
ConcurrentBag<Exception> exceptions = new ();
572621

573-
Parallel.For (
574-
0,
622+
Parallel.For (0,
575623
numTasks,
576624
i =>
577625
{
@@ -588,7 +636,7 @@ public void ThreadSafety_StressTest_RapidOperations ()
588636

589637
if (j % 100 == 0)
590638
{
591-
string searchString = navigator.SearchString;
639+
_ = navigator.SearchString;
592640
}
593641
}
594642
}

0 commit comments

Comments
 (0)