Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions Terminal.Gui/Core/SearchCollectionNavigator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using System;
using System.Linq;

namespace Terminal.Gui {
/// <summary>
/// Changes the index in a collection based on keys pressed
/// and the current state
/// </summary>
class SearchCollectionNavigator {
string state = "";
DateTime lastKeystroke = DateTime.MinValue;
const int TypingDelay = 250;
public StringComparer Comparer { get; set; } = StringComparer.InvariantCultureIgnoreCase;

public int CalculateNewIndex (string [] collection, int currentIndex, char keyStruck)
{
// if user presses a letter key
if (char.IsLetterOrDigit (keyStruck) || char.IsPunctuation (keyStruck)) {

// maybe user pressed 'd' and now presses 'd' again.
// a candidate search is things that begin with "dd"
// but if we find none then we must fallback on cycling
// d instead and discard the candidate state
string candidateState = "";

// is it a second or third (etc) keystroke within a short time
if (state.Length > 0 && DateTime.Now - lastKeystroke < TimeSpan.FromMilliseconds (TypingDelay)) {
// "dd" is a candidate
candidateState = state + keyStruck;
} else {
// its a fresh keystroke after some time
// or its first ever key press
state = new string (keyStruck, 1);
}

var idxCandidate = GetNextIndexMatching (collection, 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);

if (idxCandidate != -1) {
// found "dd" so candidate state is accepted
lastKeystroke = DateTime.Now;
state = candidateState;
return idxCandidate;
}


// nothing matches "dd" so discard it as a candidate
// and just cycle "d" instead
lastKeystroke = DateTime.Now;
idxCandidate = GetNextIndexMatching (collection, currentIndex, state);

// if no changes to current state manifested
if (idxCandidate == currentIndex || idxCandidate == -1) {
// clear history and treat as a fresh letter
ClearState ();

// match on the fresh letter alone
state = new string (keyStruck, 1);
idxCandidate = GetNextIndexMatching (collection, currentIndex, state);
return idxCandidate == -1 ? currentIndex : idxCandidate;
}

// Found another "d" or just leave index as it was
return idxCandidate;

} else {
// clear state because keypress was non letter
ClearState ();

// no change in index for non letter keystrokes
return currentIndex;
}
}

private int GetNextIndexMatching (string [] collection, int currentIndex, string search, bool preferNotToMoveToNewIndexes = false)
{
if (string.IsNullOrEmpty (search)) {
return -1;
}

// find indexes of items that start with the search text
int [] matchingIndexes = collection.Select ((item, idx) => (item, idx))
.Where (k => k.Item1?.StartsWith (search, StringComparison.InvariantCultureIgnoreCase) ?? false)
.Select (k => k.idx)
.ToArray ();

// if there are items beginning with search
if (matchingIndexes.Length > 0) {
// is one of them currently selected?
var currentlySelected = Array.IndexOf (matchingIndexes, currentIndex);

if (currentlySelected == -1) {
// we are not currently selecting any item beginning with the search
// so jump to first item in list that begins with the letter
return matchingIndexes [0];
} else {

// the current index is part of the matching collection
if (preferNotToMoveToNewIndexes) {
// if we would rather not jump around (e.g. user is typing lots of text to get this match)
return matchingIndexes [currentlySelected];
}

// cycle to next (circular)
return matchingIndexes [(currentlySelected + 1) % matchingIndexes.Length];
}
}

// nothing starts with the search
return -1;
}

private void ClearState ()
{
state = "";
lastKeystroke = DateTime.MinValue;

}
}
}
126 changes: 126 additions & 0 deletions UnitTests/SearchCollectionNavigatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using Terminal.Gui;
using Xunit;

namespace UnitTests {
public class SearchCollectionNavigatorTests {

[Fact]
public void TestSearchCollectionNavigator_Cycling ()
{
var s = new string []{
"appricot",
"arm",
"bat",
"batman",
"candle"
};

var n = new SearchCollectionNavigator ();
Assert.Equal (2, n.CalculateNewIndex (s, 0, 'b'));
Assert.Equal (3, n.CalculateNewIndex (s, 2, 'b'));

// if 4 (candle) is selected it should loop back to bat
Assert.Equal (2, n.CalculateNewIndex (s, 4, 'b'));

}


[Fact]
public void TestSearchCollectionNavigator_ToSearchText ()
{
var s = new string []{
"appricot",
"arm",
"bat",
"batman",
"bbfish",
"candle"
};

var n = new SearchCollectionNavigator ();
Assert.Equal (2, n.CalculateNewIndex (s, 0, 'b'));
Assert.Equal (4, n.CalculateNewIndex (s, 2, 'b'));

// another 'b' means searching for "bbb" which does not exist
// so we go back to looking for "b" as a fresh key strike
Assert.Equal (4, n.CalculateNewIndex (s, 2, 'b'));
}

[Fact]
public void TestSearchCollectionNavigator_FullText ()
{
var s = new string []{
"appricot",
"arm",
"ta",
"target",
"text",
"egg",
"candle"
};

var n = new SearchCollectionNavigator ();
Assert.Equal (2, n.CalculateNewIndex (s, 0, 't'));

// should match "te" in "text"
Assert.Equal (4, n.CalculateNewIndex (s, 2, 'e'));

// still matches text
Assert.Equal (4, n.CalculateNewIndex (s, 4, 'x'));

// nothing starts texa so it jumps to a for appricot
Assert.Equal (0, n.CalculateNewIndex (s, 4, 'a'));
}

[Fact]
public void TestSearchCollectionNavigator_Unicode ()
{
var s = new string []{
"appricot",
"arm",
"ta",
"丗丙业丞",
"丗丙丛",
"text",
"egg",
"candle"
};

var n = new SearchCollectionNavigator ();
Assert.Equal (3, n.CalculateNewIndex (s, 0, '丗'));

// 丗丙业丞 is as good a match as 丗丙丛
// so when doing multi character searches we should
// prefer to stay on the same index unless we invalidate
// our typed text
Assert.Equal (3, n.CalculateNewIndex (s, 3, '丙'));

// No longer matches 丗丙业丞 and now only matches 丗丙丛
// so we should move to the new match
Assert.Equal (4, n.CalculateNewIndex (s, 3, '丛'));

// nothing starts "丗丙丛a" so it jumps to a for appricot
Assert.Equal (0, n.CalculateNewIndex (s, 4, 'a'));
}

[Fact]
public void TestSearchCollectionNavigator_AtSymbol ()
{
var s = new string []{
"appricot",
"arm",
"ta",
"@bob",
"@bb",
"text",
"egg",
"candle"
};

var n = new SearchCollectionNavigator ();
Assert.Equal (3, n.CalculateNewIndex (s, 0, '@'));
Assert.Equal (3, n.CalculateNewIndex (s, 3, 'b'));
Assert.Equal (4, n.CalculateNewIndex (s, 3, 'b'));
}
}
}