diff --git a/ILSpy.Tests/Controls/SharpTreeViewTests.cs b/ILSpy.Tests/Controls/SharpTreeViewTests.cs index 786d90b9c..997c1eb8b 100644 --- a/ILSpy.Tests/Controls/SharpTreeViewTests.cs +++ b/ILSpy.Tests/Controls/SharpTreeViewTests.cs @@ -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 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().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().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() { diff --git a/ILSpy/Controls/TreeView/SharpTreeView.cs b/ILSpy/Controls/TreeView/SharpTreeView.cs index 36282fba5..8ecda6a42 100644 --- a/ILSpy/Controls/TreeView/SharpTreeView.cs +++ b/ILSpy/Controls/TreeView/SharpTreeView.cs @@ -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 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; + } + /// Selected items with no selected ancestor (used by Delete). public IEnumerable GetTopLevelSelection() {