From fb7b9eec0ec9f0591203f90b2fe150b9049271b4 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 15 May 2026 15:26:48 +0200 Subject: [PATCH] Arrow-key navigation between flag-filter chips Assisted-by: Claude:claude-opus-4-7:Claude Code Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../Views/FlagsFilterPopupKeyboardTests.cs | 123 ++++++++++++++++++ ILSpy/Controls/FlagsFilterPopup.cs | 86 ++++++++++++ ILSpy/Controls/IndependentFlagGroup.cs | 6 + ILSpy/Controls/MutexChipGroup.cs | 14 ++ 4 files changed, 229 insertions(+) create mode 100644 ILSpy.Tests/Views/FlagsFilterPopupKeyboardTests.cs diff --git a/ILSpy.Tests/Views/FlagsFilterPopupKeyboardTests.cs b/ILSpy.Tests/Views/FlagsFilterPopupKeyboardTests.cs new file mode 100644 index 000000000..029477933 --- /dev/null +++ b/ILSpy.Tests/Views/FlagsFilterPopupKeyboardTests.cs @@ -0,0 +1,123 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// 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.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Headless.NUnit; +using Avalonia.Input; +using Avalonia.VisualTree; + +using AwesomeAssertions; + +using ILSpy.Metadata.Filters; +using ILSpy.Views.Filters; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Views.Filters; + +/// +/// Pins arrow-key navigation between chips inside . +/// The popup is composed of one per axis plus an +/// ; without explicit directional-navigation wiring +/// the user can only Tab between every focusable element (chips + Clear button), which +/// is awkward for a power-user filter. Arrow keys should hop chip-by-chip. +/// +[TestFixture] +public class FlagsFilterPopupKeyboardTests +{ + [System.Flags] + enum SampleAttrs : uint + { + Public = 1, + Private = 2, + VisibilityMask = 0x3, + Static = 0x10, + Abstract = 0x20, + } + + [AvaloniaTest] + public void Right_Arrow_From_The_First_Chip_Moves_Focus_To_The_Next_Chip_In_The_Same_Group() + { + // Build the popup, push it onto a window so focus management works, focus the + // first chip, send Right. Default Avalonia DirectionalNavigation should carry + // focus through siblings inside the WrapPanel. + var schema = FlagsSchemaInferer.For(typeof(SampleAttrs)); + var state = new FilterState(schema); + var popup = new FlagsFilterPopup(state); + var window = new Window { Content = popup }; + window.Show(); + + var chips = popup.GetVisualDescendants().OfType().ToList(); + chips.Should().HaveCountGreaterThan(1, "test relies on at least two chips being present"); + var first = chips[0]; + first.Focus(); + first.IsFocused.Should().BeTrue("setup precondition — first chip must accept focus before we exercise arrow nav"); + + window.KeyPress(Key.Right, RawInputModifiers.None, PhysicalKey.ArrowRight, keySymbol: null); + + var newlyFocused = chips.SingleOrDefault(c => c.IsFocused); + newlyFocused.Should().NotBeNull("Right arrow must transfer focus to a sibling chip"); + ReferenceEquals(newlyFocused, first).Should().BeFalse( + "Right arrow must move focus AWAY from the first chip, not stay on it"); + } + + [AvaloniaTest] + public void Down_Arrow_From_A_Chip_In_The_First_Group_Lands_On_A_Chip_In_The_Next_Group() + { + // Vertical traversal: pressing Down from a chip in the first mutex group must move + // into the second group (or the independent-flags group if there's only one mutex + // group). Without arrow-traversal wiring the focus would stay inside the same + // WrapPanel because WrapPanel is a horizontal-flow container. + var schema = FlagsSchemaInferer.For(typeof(SampleAttrs)); + var state = new FilterState(schema); + var popup = new FlagsFilterPopup(state); + var window = new Window { Content = popup }; + window.Show(); + + var groups = popup.GetVisualDescendants() + .OfType() + .Where(c => c is MutexChipGroup || c is IndependentFlagGroup) + .ToList(); + groups.Should().HaveCountGreaterThan(1, + "test relies on at least two filter groups (one mutex + one independent, or two mutex)"); + + // First group is a MutexChipGroup full of ToggleButton chips; second group is an + // IndependentFlagGroup full of Button pills. Allow either control type as a valid + // landing slot so we're testing the cross-group jump, not the chip type. + var firstGroupChips = groups[0].GetVisualDescendants().OfType().ToList(); + var secondGroupTargets = groups[1].GetVisualDescendants() + .Where(d => d is ToggleButton || d is Button) + .ToList(); + var anchor = firstGroupChips[0]; + anchor.Focus(); + anchor.IsFocused.Should().BeTrue(); + + window.KeyPress(Key.Down, RawInputModifiers.None, PhysicalKey.ArrowDown, keySymbol: null); + + var focusedNow = popup.GetVisualDescendants() + .OfType() + .FirstOrDefault(c => c.IsFocused && (c is ToggleButton || c is Button)); + focusedNow.Should().NotBeNull("Down arrow must keep focus on some chip in the popup"); + secondGroupTargets.Contains(focusedNow!).Should().BeTrue( + "Down arrow from the first group must land on a chip/pill in the next group"); + } +} diff --git a/ILSpy/Controls/FlagsFilterPopup.cs b/ILSpy/Controls/FlagsFilterPopup.cs index f0abdcc34..00cf0b5c0 100644 --- a/ILSpy/Controls/FlagsFilterPopup.cs +++ b/ILSpy/Controls/FlagsFilterPopup.cs @@ -25,9 +25,12 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Styling; +using Avalonia.VisualTree; using ILSpy.Metadata.Filters; @@ -104,10 +107,93 @@ namespace ILSpy.Views.Filters Child = stack, }; + // Arrow-key nav between chips. Avalonia doesn't ship WPF's DirectionalNavigation + // attached property, so we handle Left/Right/Up/Down ourselves. Tunnel so we + // intercept before inner controls (the Clear button consumes Enter; arrow keys + // otherwise pass through to whatever has focus and do nothing). + AddHandler(KeyDownEvent, OnArrowKeyDown, RoutingStrategies.Tunnel); + state.PropertyChanged += OnStateChanged; RefreshSummary(); } + void OnArrowKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key is not (Key.Left or Key.Right or Key.Up or Key.Down)) + return; + // e.Source on a tunneled key event is the focused element about to receive it, + // regardless of whether FocusManager has stabilised in this headless tick. + // Fall back to FocusManager for completeness — both should usually agree. + var focused = (e.Source as Control) + ?? (TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as Control); + if (focused is null) + return; + + var groups = this.GetVisualDescendants() + .Where(c => c is MutexChipGroup or IndependentFlagGroup) + .OfType() + .ToList(); + if (groups.Count == 0) + return; + + Control? target = null; + if (e.Key is Key.Left or Key.Right) + { + // Flat document-order traversal: every chip / pill across the whole popup + // is one sequence. Left = previous, Right = next, wrap at ends so the user + // doesn't get stuck. + var flat = ChipsInDocumentOrder(groups).ToList(); + int idx = flat.IndexOf(focused); + if (idx < 0) + return; + int step = e.Key == Key.Right ? 1 : -1; + target = flat[(idx + step + flat.Count) % flat.Count]; + } + else + { + // Up / Down: jump to the previous / next group's first chip. Lets the user + // hop between axes without arrowing through every value chip in between. + int currentGroup = FindGroupContaining(groups, focused); + if (currentGroup < 0) + return; + int step = e.Key == Key.Down ? 1 : -1; + int newGroup = currentGroup + step; + if (newGroup < 0 || newGroup >= groups.Count) + return; + target = ChipsIn(groups[newGroup]).FirstOrDefault(); + } + + if (target is not null) + { + target.Focus(); + e.Handled = true; + } + } + + static int FindGroupContaining(IReadOnlyList groups, Control element) + { + for (int i = 0; i < groups.Count; i++) + if (groups[i] == element || groups[i].GetVisualDescendants().Contains(element)) + return i; + return -1; + } + + // Each group exposes its own NavigableElements list — using that instead of a + // blanket descendant-walk skips past internal template parts (e.g. ComboBox's + // own toggle button) so arrow nav lands only on user-facing chips/pills. + static IEnumerable ChipsIn(Control group) => group switch { + MutexChipGroup mutex => mutex.NavigableElements, + IndependentFlagGroup indep => indep.NavigableElements, + _ => Enumerable.Empty(), + }; + + static IEnumerable ChipsInDocumentOrder(IEnumerable groups) + { + foreach (var group in groups) + foreach (var chip in ChipsIn(group)) + yield return chip; + } + void OnStateChanged(object? sender, PropertyChangedEventArgs e) => RefreshSummary(); void RefreshSummary() => summary.Text = FilterStatePresenter.Describe(state); diff --git a/ILSpy/Controls/IndependentFlagGroup.cs b/ILSpy/Controls/IndependentFlagGroup.cs index bcdee711f..dc6412a99 100644 --- a/ILSpy/Controls/IndependentFlagGroup.cs +++ b/ILSpy/Controls/IndependentFlagGroup.cs @@ -41,6 +41,12 @@ namespace ILSpy.Views.Filters readonly Dictionary pills = new(); readonly ComboBox modeBox; + /// + /// Every user-facing pill in document order. Used by the popup's arrow-key + /// navigation to skip past internal template parts of the All/Any ComboBox. + /// + public IEnumerable NavigableElements => pills.Values; + public IndependentFlagGroup(FilterState state, IReadOnlyList flags) { this.state = state ?? throw new ArgumentNullException(nameof(state)); diff --git a/ILSpy/Controls/MutexChipGroup.cs b/ILSpy/Controls/MutexChipGroup.cs index 457aaa31f..ab8ae4d16 100644 --- a/ILSpy/Controls/MutexChipGroup.cs +++ b/ILSpy/Controls/MutexChipGroup.cs @@ -48,6 +48,20 @@ namespace ILSpy.Views.Filters readonly Dictionary valueChips = new(); bool suppress; + /// + /// Every user-facing chip in document order — the Any-chip first, then each + /// value chip. Used by the popup's arrow-key navigation to skip past internal + /// template parts (ComboBox toggle buttons etc.) that descendant-walking would + /// otherwise pick up. + /// + public IEnumerable NavigableElements { + get { + yield return anyChip; + foreach (var (_, chip) in valueChips) + yield return chip; + } + } + public MutexChipGroup(FilterState state, MutexGroup group) { this.state = state ?? throw new ArgumentNullException(nameof(state));