Browse Source

Enlarge assembly-tree expander click target to 16x16

The +/- expander was a 13x13 toggle whose visible glyph (a 9x9 box) was
also the only hit-testable surface, so the real tap target was barely
9px. Grow the toggle to 16x16 but keep its laid-out width at 13 via a
negative right margin, because TreeLines hardcodes a 13px expander
column with the glyph centred at +8.5 and would otherwise misalign. A
transparent wrapper fills the 16x16 so the whole area receives input;
the visible glyph is unchanged.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
905f6fb1c7
  1. 126
      ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs
  2. 66
      ILSpy/App.axaml

126
ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs

@ -0,0 +1,126 @@
// 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 System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Shapes;
using Avalonia.Headless;
using Avalonia.Headless.NUnit;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AwesomeAssertions;
using ICSharpCode.ILSpyX.TreeView;
using ILSpy.AppEnv;
using ILSpy.AssemblyTree;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class AssemblyTreeExpanderHitboxTests
{
[AvaloniaTest]
public async Task Expander_Toggle_Offers_At_Least_16x16_Clickable_Target()
{
// The +/- expander in the assembly tree must give a click target of at least 16x16 for
// reliable tapping, while its visible glyph stays the classic 9x9 box and the column /
// tree-line layout (which assume a 13px expander column, glyph centred at +8.5) is left
// unchanged. The target must be genuinely hittable across the full 16x16 — not merely
// occupy 16x16 of layout while only the 9x9 glyph receives input.
// Arrange — boot, wait for assemblies, expand a node so an expandable row is realised.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.EnsureLazyChildren();
assemblyNode.IsExpanded = true;
vm.AssemblyTreeModel.SelectNode(assemblyNode);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
// Let rows realise and layout settle.
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
grid.UpdateLayout();
await Task.Delay(25);
}
// Act — locate the expander toggle of the (expandable) assembly row.
var row = grid.GetVisualDescendants().OfType<DataGridRow>()
.FirstOrDefault(r => RowMatches(r, assemblyNode));
row.Should().NotBeNull("the expanded assembly row must be realised");
var expander = row!.GetVisualDescendants().OfType<ToggleButton>()
.FirstOrDefault(b => b.Name == "PART_Expander");
expander.Should().NotBeNull("an expandable row must realise a PART_Expander toggle");
expander!.IsEnabled.Should().BeTrue("the assembly row is expandable");
// Assert — the click target is at least 16x16.
expander.Bounds.Width.Should().BeGreaterThanOrEqualTo(16,
"the expander click target must be at least 16px wide for reliable tapping");
expander.Bounds.Height.Should().BeGreaterThanOrEqualTo(16,
"the expander click target must be at least 16px tall for reliable tapping");
// Assert — the visible glyph box is unchanged at 9x9 (the nearest Border around ExpandPath,
// i.e. the drawn box, not the transparent hit-target wrapper).
var glyphPath = expander.GetVisualDescendants().OfType<Path>()
.FirstOrDefault(p => p.Name == "ExpandPath");
glyphPath.Should().NotBeNull("the expander must render its ExpandPath glyph");
var glyph = glyphPath!.GetVisualAncestors().OfType<Border>().FirstOrDefault();
glyph.Should().NotBeNull("the expander must still render its glyph box");
glyph!.Bounds.Width.Should().BeApproximately(9, 0.5, "the visible glyph box must stay 9px wide");
glyph.Bounds.Height.Should().BeApproximately(9, 0.5, "the visible glyph box must stay 9px tall");
// Assert — a real click well below the 9x9 glyph (y=14, inside the 16-tall target but
// outside the centred glyph at ~y=3.5..12.5) collapses the node. This proves the grown
// area is genuinely hittable, not just larger in layout.
assemblyNode.IsExpanded.Should().BeTrue("precondition: node is expanded before the click");
var hitPoint = expander.TranslatePoint(new Point(expander.Bounds.Width / 2, 14), window);
hitPoint.Should().NotBeNull();
HeadlessWindowExtensions.MouseDown(window, hitPoint!.Value, MouseButton.Left);
HeadlessWindowExtensions.MouseUp(window, hitPoint.Value, MouseButton.Left);
await Waiters.WaitForAsync(() => !assemblyNode.IsExpanded,
description: "clicking the enlarged expander area (below the glyph) must toggle the node");
}
static bool RowMatches(DataGridRow row, SharpTreeNode target)
{
var ctx = row.DataContext;
if (ReferenceEquals(ctx, target))
return true;
var itemProp = ctx?.GetType().GetProperty("Item");
return itemProp is not null && ReferenceEquals(itemProp.GetValue(ctx), target);
}
}

66
ILSpy/App.axaml

@ -112,8 +112,9 @@
via the style below. --> via the style below. -->
<docking:DockableViewRecycling x:Key="ControlRecyclingKey" /> <docking:DockableViewRecycling x:Key="ControlRecyclingKey" />
<!-- Override ProDataGrid's hierarchical expander to a classic Windows-Explorer-sized 9x9 box, <!-- Override ProDataGrid's hierarchical expander to a classic Windows-Explorer-sized 9x9 box.
plus 4px of left padding (so the ToggleButton itself is 13x9). --> 13 is the laid-out column footprint (glyph + 4px left padding); the actual click target
is grown to 16x16 in the PART_Expander template below without disturbing this footprint. -->
<x:Double x:Key="DataGridHierarchicalExpanderSize">13</x:Double> <x:Double x:Key="DataGridHierarchicalExpanderSize">13</x:Double>
<x:Double x:Key="DataGridHierarchicalExpanderGlyphSize">5</x:Double> <x:Double x:Key="DataGridHierarchicalExpanderGlyphSize">5</x:Double>
@ -275,30 +276,37 @@
<!-- Classic Windows-Explorer +/- expander. Colors are hardcoded because the <!-- Classic Windows-Explorer +/- expander. Colors are hardcoded because the
presenter's :checked style forces Background/BorderBrush to Transparent presenter's :checked style forces Background/BorderBrush to Transparent
through TemplateBinding. The Margin offsets the visible glyph from the through TemplateBinding. The 4px left margin (Left-aligned, not centred)
presenter cell's left edge — the presenter itself overwrites its Padding pins the visible glyph at x=4..13 inside the 16px-wide hit target, i.e.
from Level * Indent in code. --> centred at +8.5 — the position TreeLines draws to — regardless of the
enlarged button. The presenter overwrites its Padding from Level * Indent
in code. -->
<Style Selector="DataGridHierarchicalPresenter /template/ ToggleButton#PART_Expander"> <Style Selector="DataGridHierarchicalPresenter /template/ ToggleButton#PART_Expander">
<Setter Property="Padding" Value="0" /> <Setter Property="Padding" Value="0" />
<Setter Property="Template"> <Setter Property="Template">
<ControlTemplate TargetType="ToggleButton"> <ControlTemplate TargetType="ToggleButton">
<Border Width="9" Height="9" <!-- Transparent surface filling the whole 16x16 button so the entire target
Margin="4,0,0,0" is hit-testable, not just the 9x9 glyph. Disabled (leaf) rows don't
BorderThickness="1" hit-test, so this never steals clicks from non-expandable rows. -->
BorderBrush="#828790" <Border Background="Transparent">
CornerRadius="1" <Border Width="9" Height="9"
HorizontalAlignment="Center" Margin="4,0,0,0"
VerticalAlignment="Center"> BorderThickness="1"
<Border.Background> BorderBrush="#828790"
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%"> CornerRadius="1"
<GradientStop Color="White" Offset="0.2" /> HorizontalAlignment="Left"
<GradientStop Color="#FFC0B7A6" Offset="1" /> VerticalAlignment="Center">
</LinearGradientBrush> <Border.Background>
</Border.Background> <LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
<Path Name="ExpandPath" <GradientStop Color="White" Offset="0.2" />
Margin="1" <GradientStop Color="#FFC0B7A6" Offset="1" />
Fill="Black" </LinearGradientBrush>
Data="M 0 2 L 0 3 L 2 3 L 2 5 L 3 5 L 3 3 L 5 3 L 5 2 L 3 2 L 3 0 L 2 0 L 2 2 Z" /> </Border.Background>
<Path Name="ExpandPath"
Margin="1"
Fill="Black"
Data="M 0 2 L 0 3 L 2 3 L 2 5 L 3 5 L 3 3 L 5 3 L 5 2 L 3 2 L 3 0 L 2 0 L 2 2 Z" />
</Border>
</Border> </Border>
</ControlTemplate> </ControlTemplate>
</Setter> </Setter>
@ -387,12 +395,20 @@
Level="{Binding Level, RelativeSource={RelativeSource TemplatedParent}}" /> Level="{Binding Level, RelativeSource={RelativeSource TemplatedParent}}" />
<Border Padding="{TemplateBinding Padding}"> <Border Padding="{TemplateBinding Padding}">
<Grid ColumnDefinitions="Auto,*"> <Grid ColumnDefinitions="Auto,*">
<!-- Hit target is 16x16 for reliable tapping, but the negative
right margin keeps the laid-out width at 13 (16 - 3) so the
Auto column, content offset, and TreeLines (which assume a
13px expander column, glyph centred at +8.5) are unchanged.
The 3px overflow falls into the content-margin gap on the
right; growing leftwards would land outside the row at level 0.
The button's own template keeps the visible glyph a 9x9 box. -->
<ToggleButton x:Name="PART_Expander" <ToggleButton x:Name="PART_Expander"
Width="{DynamicResource DataGridHierarchicalExpanderSize}" Width="16"
Height="{DynamicResource DataGridHierarchicalExpanderSize}" Height="16"
Margin="0,0,-3,0"
Theme="{DynamicResource DataGridHierarchicalExpanderTheme}" Theme="{DynamicResource DataGridHierarchicalExpanderTheme}"
IsEnabled="{TemplateBinding IsExpandable}" IsEnabled="{TemplateBinding IsExpandable}"
HorizontalAlignment="Center" HorizontalAlignment="Left"
VerticalAlignment="Center" VerticalAlignment="Center"
IsChecked="{Binding IsExpanded, RelativeSource={RelativeSource TemplatedParent}}" /> IsChecked="{Binding IsExpanded, RelativeSource={RelativeSource TemplatedParent}}" />
<ContentPresenter Grid.Column="1" <ContentPresenter Grid.Column="1"

Loading…
Cancel
Save