Browse Source

Add type-ahead search; prove native shift-range extend/shrink

OnTextInput drives incremental prefix search over the visible (flattened) nodes with a 1s idle reset -- a fresh keystroke advances past the current row, a growing prefix refines forward. A regression test confirms the payoff of the ListBox base: SelectionMode.Multiple extends AND shrinks a shift-range from the anchor (Shift+Down x2 then Shift+Up -> A,B), so the migrated control needs none of the ProDataGrid reflection workaround.
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
3e8e08644b
  1. 61
      ILSpy.Tests/Controls/SharpTreeViewTests.cs
  2. 58
      ILSpy/Controls/TreeView/SharpTreeView.cs

61
ILSpy.Tests/Controls/SharpTreeViewTests.cs

@ -16,8 +16,12 @@ @@ -16,8 +16,12 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Linq;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.NUnit;
using Avalonia.Input;
using Avalonia.Threading;
using AwesomeAssertions;
@ -74,6 +78,63 @@ public class SharpTreeViewTests @@ -74,6 +78,63 @@ public class SharpTreeViewTests
tree.ItemCount.Should().Be(3, "collapsing B hides B1 again");
}
[AvaloniaTest]
public void Shift_Down_Extends_And_Shift_Up_Shrinks_From_The_Anchor()
{
// The whole reason for the rewrite: native ListBox anchor selection both extends AND
// shrinks a shift-range -- the thing ProDataGrid's additive SetRowsSelection could not do.
var nodes = Enumerable.Range(0, 5).Select(i => new TestNode(((char)('A' + i)).ToString())).ToArray();
var root = new TestNode("root", nodes);
var tree = new SharpTreeView { ShowRoot = false, Root = root };
var window = new Window { Content = tree, Width = 300, Height = 400 };
window.Show();
Dispatcher.UIThread.RunJobs();
tree.SelectedItem = nodes[0];
Dispatcher.UIThread.RunJobs();
(tree.ContainerFromItem(nodes[0]))?.Focus();
Dispatcher.UIThread.RunJobs();
window.KeyPress(Key.Down, RawInputModifiers.Shift, PhysicalKey.ArrowDown, null);
Dispatcher.UIThread.RunJobs();
window.KeyPress(Key.Down, RawInputModifiers.Shift, PhysicalKey.ArrowDown, null);
Dispatcher.UIThread.RunJobs();
tree.SelectedItems!.Cast<SharpTreeNode>().Should()
.BeEquivalentTo(new[] { nodes[0], nodes[1], nodes[2] }, "Shift+Down twice extends A..C");
window.KeyPress(Key.Up, RawInputModifiers.Shift, PhysicalKey.ArrowUp, null);
Dispatcher.UIThread.RunJobs();
tree.SelectedItems!.Cast<SharpTreeNode>().Should()
.BeEquivalentTo(new[] { nodes[0], nodes[1] }, "Shift+Up shrinks the range back toward the anchor (A,B)");
}
[AvaloniaTest]
public void Type_Ahead_Jumps_To_The_Visible_Node_Matching_The_Typed_Prefix()
{
var apple = new TestNode("Apple");
var banana = new TestNode("Banana");
var cherry = new TestNode("Cherry");
var cranberry = new TestNode("Cranberry");
var root = new TestNode("root", apple, banana, cherry, cranberry);
var tree = new SharpTreeView { ShowRoot = false, Root = root };
var window = new Window { Content = tree, Width = 300, Height = 400 };
window.Show();
Dispatcher.UIThread.RunJobs();
// "cra": 'c' lands on Cherry, 'r' has to search forward to Cranberry, 'a' stays.
foreach (char ch in "cra")
{
tree.RaiseEvent(new TextInputEventArgs {
RoutedEvent = InputElement.TextInputEvent,
Text = ch.ToString(),
Source = tree,
});
Dispatcher.UIThread.RunJobs();
}
tree.SelectedItem.Should().BeSameAs(cranberry, "type-ahead refines forward to the Cr* match");
}
[AvaloniaTest]
public void Selecting_A_Row_Sets_The_Node_IsSelected()
{

58
ILSpy/Controls/TreeView/SharpTreeView.cs

@ -55,6 +55,8 @@ namespace ILSpy.Controls.TreeView @@ -55,6 +55,8 @@ namespace ILSpy.Controls.TreeView
TreeFlattener? flattener;
bool updatesLocked;
bool doNotScrollOnExpanding;
string searchBuffer = string.Empty;
DispatcherTimer? searchResetTimer;
static SharpTreeView()
{
@ -288,6 +290,62 @@ namespace ILSpy.Controls.TreeView @@ -288,6 +290,62 @@ namespace ILSpy.Controls.TreeView
ExpandRecursively(child);
}
protected override void OnTextInput(TextInputEventArgs e)
{
var text = e.Text;
if (!string.IsNullOrEmpty(text) && !char.IsControl(text[0]) && flattener is { Count: > 0 })
{
searchBuffer += text;
RestartSearchTimer();
// A fresh single keystroke advances past the current row (so repeating a letter
// cycles); a growing prefix re-matches from the current row so a settled match stays.
int anchor = SelectedItem is SharpTreeNode current ? flattener.IndexOf(current) : -1;
int from = searchBuffer.Length <= 1 ? anchor + 1 : anchor;
if (FindPrefixMatch(from) is { } match)
{
SelectedItem = match;
FocusNode(match);
}
e.Handled = true;
}
if (!e.Handled)
base.OnTextInput(e);
}
SharpTreeNode? FindPrefixMatch(int from)
{
if (flattener is null)
return null;
int count = flattener.Count;
if (from < 0)
from = 0;
for (int k = 0; k < count; k++)
{
if (flattener[(from + k) % count] is SharpTreeNode node
&& node.Text?.ToString() is { } text
&& text.StartsWith(searchBuffer, StringComparison.OrdinalIgnoreCase))
{
return node;
}
}
return null;
}
void RestartSearchTimer()
{
searchResetTimer ??= new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
searchResetTimer.Stop();
searchResetTimer.Tick -= OnSearchTimeout;
searchResetTimer.Tick += OnSearchTimeout;
searchResetTimer.Start();
}
void OnSearchTimeout(object? sender, EventArgs e)
{
searchResetTimer?.Stop();
searchBuffer = string.Empty;
}
/// <summary>Selected items with no selected ancestor (used by Delete).</summary>
public IEnumerable<SharpTreeNode> GetTopLevelSelection()
{

Loading…
Cancel
Save