Browse Source

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
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
fb7b9eec0e
  1. 123
      ILSpy.Tests/Views/FlagsFilterPopupKeyboardTests.cs
  2. 86
      ILSpy/Controls/FlagsFilterPopup.cs
  3. 6
      ILSpy/Controls/IndependentFlagGroup.cs
  4. 14
      ILSpy/Controls/MutexChipGroup.cs

123
ILSpy.Tests/Views/FlagsFilterPopupKeyboardTests.cs

@ -0,0 +1,123 @@ @@ -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;
/// <summary>
/// Pins arrow-key navigation between chips inside <see cref="FlagsFilterPopup"/>.
/// The popup is composed of one <see cref="MutexChipGroup"/> per axis plus an
/// <see cref="IndependentFlagGroup"/>; 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.
/// </summary>
[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<ToggleButton>().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<UserControl>()
.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<ToggleButton>().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<Control>()
.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");
}
}

86
ILSpy/Controls/FlagsFilterPopup.cs

@ -25,9 +25,12 @@ using Avalonia; @@ -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 @@ -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<Control>()
.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<Control> 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<Control> ChipsIn(Control group) => group switch {
MutexChipGroup mutex => mutex.NavigableElements,
IndependentFlagGroup indep => indep.NavigableElements,
_ => Enumerable.Empty<Control>(),
};
static IEnumerable<Control> ChipsInDocumentOrder(IEnumerable<Control> 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);

6
ILSpy/Controls/IndependentFlagGroup.cs

@ -41,6 +41,12 @@ namespace ILSpy.Views.Filters @@ -41,6 +41,12 @@ namespace ILSpy.Views.Filters
readonly Dictionary<string, Button> pills = new();
readonly ComboBox modeBox;
/// <summary>
/// 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.
/// </summary>
public IEnumerable<Control> NavigableElements => pills.Values;
public IndependentFlagGroup(FilterState state, IReadOnlyList<IndependentFlag> flags)
{
this.state = state ?? throw new ArgumentNullException(nameof(state));

14
ILSpy/Controls/MutexChipGroup.cs

@ -48,6 +48,20 @@ namespace ILSpy.Views.Filters @@ -48,6 +48,20 @@ namespace ILSpy.Views.Filters
readonly Dictionary<uint, ToggleButton> valueChips = new();
bool suppress;
/// <summary>
/// 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.
/// </summary>
public IEnumerable<Control> 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));

Loading…
Cancel
Save