Browse Source

Merge pull request #10944 from AvaloniaUI/fixes/10249-itemscontrol-isselected-binding

Fix binding container IsSelected in styles/themes
pull/10892/head
Max Katz 3 years ago
committed by GitHub
parent
commit
01b2e91e46
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 13
      src/Avalonia.Controls/ISelectable.cs
  2. 16
      src/Avalonia.Controls/ItemsControl.cs
  3. 3
      src/Avalonia.Controls/ListBoxItem.cs
  4. 2
      src/Avalonia.Controls/MenuItem.cs
  5. 123
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  6. 2
      src/Avalonia.Controls/TabItem.cs
  7. 76
      src/Avalonia.Controls/TreeView.cs
  8. 7
      src/Avalonia.Controls/TreeViewItem.cs
  9. 475
      tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs
  10. 7
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs
  11. 1
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs
  12. 1596
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs
  13. 249
      tests/Avalonia.Controls.UnitTests/TreeViewTests.cs

13
src/Avalonia.Controls/ISelectable.cs

@ -1,16 +1,9 @@
using Avalonia.Controls.Primitives;
namespace Avalonia.Controls
{
/// <summary>
/// Interface for objects that are selectable.
/// An interface that is implemented by objects that expose their selection state via a
/// boolean <see cref="IsSelected"/> property.
/// </summary>
/// <remarks>
/// Controls such as <see cref="SelectingItemsControl"/> use this interface to indicate the
/// selected control in a list. If changing the control's <see cref="IsSelected"/> property
/// should update the selection in a <see cref="SelectingItemsControl"/> or equivalent, then
/// the control should raise the <see cref="SelectingItemsControl.IsSelectedChangedEvent"/>.
/// </remarks>
public interface ISelectable
{
/// <summary>
@ -18,4 +11,4 @@ namespace Avalonia.Controls
/// </summary>
bool IsSelected { get; set; }
}
}
}

16
src/Avalonia.Controls/ItemsControl.cs

@ -424,6 +424,21 @@ namespace Avalonia.Controls
}
}
/// <summary>
/// Called when a container has been fully prepared to display an item.
/// </summary>
/// <param name="container">The container control.</param>
/// <param name="item">The item being displayed.</param>
/// <param name="index">The index of the item being displayed.</param>
/// <remarks>
/// This method will be called when a container has been fully prepared and added to the
/// logical and visual trees, but may be called before a layout pass has completed. It is
/// called immediately before the <see cref="ContainerPrepared"/> event is raised.
/// </remarks>
protected internal virtual void ContainerForItemPreparedOverride(Control container, object? item, int index)
{
}
/// <summary>
/// Called when the index for a container changes due to an insertion or removal in the
/// items collection.
@ -654,6 +669,7 @@ namespace Avalonia.Controls
internal void ItemContainerPrepared(Control container, object? item, int index)
{
ContainerForItemPreparedOverride(container, item, index);
_childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(container, index));
ContainerPrepared?.Invoke(this, new(container, index));
}

3
src/Avalonia.Controls/ListBoxItem.cs

@ -1,6 +1,7 @@
using Avalonia.Automation.Peers;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Mixins;
using Avalonia.Controls.Primitives;
namespace Avalonia.Controls
{
@ -14,7 +15,7 @@ namespace Avalonia.Controls
/// Defines the <see cref="IsSelected"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsSelectedProperty =
AvaloniaProperty.Register<ListBoxItem, bool>(nameof(IsSelected));
SelectingItemsControl.IsSelectedProperty.AddOwner<ListBoxItem>();
/// <summary>
/// Initializes static members of the <see cref="ListBoxItem"/> class.

2
src/Avalonia.Controls/MenuItem.cs

@ -57,7 +57,7 @@ namespace Avalonia.Controls
/// Defines the <see cref="IsSelected"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsSelectedProperty =
ListBoxItem.IsSelectedProperty.AddOwner<MenuItem>();
SelectingItemsControl.IsSelectedProperty.AddOwner<MenuItem>();
/// <summary>
/// Defines the <see cref="IsSubMenuOpen"/> property.

123
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@ -104,6 +104,14 @@ namespace Avalonia.Controls.Primitives
AvaloniaProperty.Register<SelectingItemsControl, SelectionMode>(
nameof(SelectionMode));
/// <summary>
/// Defines the IsSelected attached property.
/// </summary>
public static readonly StyledProperty<bool> IsSelectedProperty =
AvaloniaProperty.RegisterAttached<SelectingItemsControl, Control, bool>(
"IsSelected",
defaultBindingMode: BindingMode.TwoWay);
/// <summary>
/// Defines the <see cref="IsTextSearchEnabled"/> property.
/// </summary>
@ -111,9 +119,8 @@ namespace Avalonia.Controls.Primitives
AvaloniaProperty.Register<SelectingItemsControl, bool>(nameof(IsTextSearchEnabled), false);
/// <summary>
/// Event that should be raised by items that implement <see cref="ISelectable"/> to
/// notify the parent <see cref="SelectingItemsControl"/> that their selection state
/// has changed.
/// Event that should be raised by containers when their selection state changes to notify
/// the parent <see cref="SelectingItemsControl"/> that their selection state has changed.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> IsSelectedChangedEvent =
RoutedEvent.Register<SelectingItemsControl, RoutedEventArgs>(
@ -302,20 +309,9 @@ namespace Avalonia.Controls.Primitives
{
get
{
if (_updateState?.Selection.HasValue == true)
{
return _updateState.Selection.Value;
}
else
{
if (_selection is null)
{
_selection = CreateDefaultSelectionModel();
InitializeSelectionModel(_selection);
}
return _selection;
}
return _updateState?.Selection.HasValue == true ?
_updateState.Selection.Value :
GetOrCreateSelectionModel();
}
set
{
@ -420,6 +416,21 @@ namespace Avalonia.Controls.Primitives
/// <param name="item">The item.</param>
public void ScrollIntoView(object item) => ScrollIntoView(ItemsView.IndexOf(item));
/// <summary>
/// Gets the value of the <see cref="IsSelectedProperty"/> on the specified control.
/// </summary>
/// <param name="control">The control.</param>
/// <returns>The value of the attached property.</returns>
public static bool GetIsSelected(Control control) => control.GetValue(IsSelectedProperty);
/// <summary>
/// Gets the value of the <see cref="IsSelectedProperty"/> on the specified control.
/// </summary>
/// <param name="control">The control.</param>
/// <param name="value">The value of the property.</param>
/// <returns>The value of the attached property.</returns>
public static void SetIsSelected(Control control, bool value) => control.SetValue(IsSelectedProperty, value);
/// <summary>
/// Tries to get the container that was the source of an event.
/// </summary>
@ -473,20 +484,36 @@ namespace Avalonia.Controls.Primitives
}
}
/// <inheritdoc />
protected internal override void PrepareContainerForItemOverride(Control element, object? item, int index)
protected internal override void PrepareContainerForItemOverride(Control container, object? item, int index)
{
// Ensure that the selection model is created at this point so that accessing it in
// ContainerForItemPreparedOverride doesn't cause it to be initialized (which can
// make containers become deselected when they're synced with the empty selection
// mode).
GetOrCreateSelectionModel();
base.PrepareContainerForItemOverride(container, item, index);
}
protected internal override void ContainerForItemPreparedOverride(Control container, object? item, int index)
{
base.PrepareContainerForItemOverride(element, item, index);
base.ContainerForItemPreparedOverride(container, item, index);
if ((element as ISelectable)?.IsSelected == true)
// Once the container has been full prepared and added to the tree, any bindings from
// styles or item container themes are guaranteed to be applied.
if (!container.IsSet(IsSelectedProperty))
{
Selection.Select(index);
MarkContainerSelected(element, true);
// The IsSelected property is not set on the container: update the container
// selection based on the current selection as understood by this control.
MarkContainerSelected(container, Selection.IsSelected(index));
}
else
{
var selected = Selection.IsSelected(index);
MarkContainerSelected(element, selected);
// The IsSelected property is set on the container: there is a style or item
// container theme which has bound the IsSelected property. Update our selection
// based on the selection state of the container.
var containerIsSelected = GetIsSelected(container);
UpdateSelection(index, containerIsSelected, toggleModifier: true);
}
}
@ -508,8 +535,7 @@ namespace Avalonia.Controls.Primitives
KeyboardNavigation.SetTabOnceActiveElement(panel, null);
}
if (element is ISelectable)
MarkContainerSelected(element, false);
element.ClearValue(IsSelectedProperty);
}
/// <inheritdoc/>
@ -874,6 +900,17 @@ namespace Avalonia.Controls.Primitives
return false;
}
private ISelectionModel GetOrCreateSelectionModel()
{
if (_selection is null)
{
_selection = CreateDefaultSelectionModel();
InitializeSelectionModel(_selection);
}
return _selection;
}
private void OnItemsViewSourceChanged(object? sender, EventArgs e)
{
if (_selection is not null && _updateState is null)
@ -1098,11 +1135,14 @@ namespace Avalonia.Controls.Primitives
{
if (!_ignoreContainerSelectionChanged &&
e.Source is Control control &&
e.Source is ISelectable selectable &&
control.Parent == this &&
IndexFromContainer(control) != -1)
IndexFromContainer(control) is var index &&
index >= 0)
{
UpdateSelection(control, selectable.IsSelected);
if (GetIsSelected(control))
Selection.Select(index);
else
Selection.Deselect(index);
}
if (e.Source != this)
@ -1112,31 +1152,18 @@ namespace Avalonia.Controls.Primitives
}
/// <summary>
/// Sets a container's 'selected' class or <see cref="ISelectable.IsSelected"/>.
/// Sets the <see cref="IsSelectedProperty"/> on the specified container.
/// </summary>
/// <param name="container">The container.</param>
/// <param name="selected">Whether the control is selected</param>
/// <returns>The previous selection state.</returns>
private bool MarkContainerSelected(Control container, bool selected)
private void MarkContainerSelected(Control container, bool selected)
{
_ignoreContainerSelectionChanged = true;
try
{
bool result;
_ignoreContainerSelectionChanged = true;
if (container is ISelectable selectable)
{
result = selectable.IsSelected;
selectable.IsSelected = selected;
}
else
{
result = container.Classes.Contains(":selected");
((IPseudoClasses)container.Classes).Set(":selected", selected);
}
return result;
container.SetCurrentValue(IsSelectedProperty, selected);
}
finally
{

2
src/Avalonia.Controls/TabItem.cs

@ -22,7 +22,7 @@ namespace Avalonia.Controls
/// Defines the <see cref="IsSelected"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsSelectedProperty =
ListBoxItem.IsSelectedProperty.AddOwner<TabItem>();
SelectingItemsControl.IsSelectedProperty.AddOwner<TabItem>();
/// <summary>
/// Initializes static members of the <see cref="TabItem"/> class.

76
src/Avalonia.Controls/TreeView.cs

@ -10,6 +10,7 @@ using Avalonia.Controls.Generators;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Threading;
using Avalonia.VisualTree;
@ -60,7 +61,8 @@ namespace Avalonia.Controls
/// </summary>
static TreeView()
{
// HACK: Needed or SelectedItem property will not be found in Release build.
SelectingItemsControl.IsSelectedChangedEvent.AddClassHandler<TreeView>((x, e) =>
x.ContainerSelectionChanged(e));
}
/// <summary>
@ -430,9 +432,8 @@ namespace Avalonia.Controls
private void MarkItemSelected(object item, bool selected)
{
var container = TreeContainerFromItem(item)!;
MarkContainerSelected(container, selected);
if (TreeContainerFromItem(item) is Control container)
MarkContainerSelected(container, selected);
}
private void SelectedItemsAdded(IList items)
@ -487,16 +488,24 @@ namespace Avalonia.Controls
protected internal override Control CreateContainerForItemOverride() => new TreeViewItem();
protected internal override bool IsItemItsOwnContainerOverride(Control item) => item is TreeViewItem;
protected internal override void PrepareContainerForItemOverride(Control container, object? item, int index)
protected internal override void ContainerForItemPreparedOverride(Control container, object? item, int index)
{
base.PrepareContainerForItemOverride(container, item, index);
base.ContainerForItemPreparedOverride(container, item, index);
if (item == SelectedItem)
// Once the container has been full prepared and added to the tree, any bindings from
// styles or item container themes are guaranteed to be applied.
if (container.IsSet(SelectingItemsControl.IsSelectedProperty))
{
MarkContainerSelected(container, true);
if (AutoScrollToSelectedItem)
Dispatcher.UIThread.Post(container.BringIntoView);
// The IsSelected property is set on the container: there is a style or item
// container theme which has bound the IsSelected property. Update our selection
// based on the selection state of the container.
var containerIsSelected = SelectingItemsControl.GetIsSelected(container);
UpdateSelectionFromContainer(container, select: containerIsSelected, toggleModifier: true);
}
// The IsSelected property is not set on the container: update the container
// selection based on the current selection as understood by this control.
MarkContainerSelected(container, SelectedItems.Contains(item));
}
/// <inheritdoc/>
@ -663,7 +672,11 @@ namespace Avalonia.Controls
var multi = mode.HasAllFlags(SelectionMode.Multiple);
var range = multi && rangeModifier && selectedContainer != null;
if (rightButton)
if (!select)
{
SelectedItems.Remove(item);
}
else if (rightButton)
{
if (!SelectedItems.Contains(item))
{
@ -863,27 +876,44 @@ namespace Avalonia.Controls
}
/// <summary>
/// Sets a container's 'selected' class or <see cref="ISelectable.IsSelected"/>.
/// Called when a container raises the
/// <see cref="SelectingItemsControl.IsSelectedChangedEvent"/>.
/// </summary>
/// <param name="container">The container.</param>
/// <param name="selected">Whether the control is selected</param>
private void MarkContainerSelected(Control? container, bool selected)
/// <param name="e">The event.</param>
private void ContainerSelectionChanged(RoutedEventArgs e)
{
if (container == null)
if (e.Source is TreeViewItem container &&
container.TreeViewOwner == this &&
TreeItemFromContainer(container) is object item)
{
return;
}
var containerIsSelected = SelectingItemsControl.GetIsSelected(container);
var ourIsSelected = SelectedItems.Contains(item);
if (container is ISelectable selectable)
{
selectable.IsSelected = selected;
if (containerIsSelected != ourIsSelected)
{
if (containerIsSelected)
SelectedItems.Add(item);
else
SelectedItems.Remove(item);
}
}
else
if (e.Source != this)
{
((IPseudoClasses)container.Classes).Set(":selected", selected);
e.Handled = true;
}
}
/// <summary>
/// Sets a container's 'selected' class or <see cref="ISelectable.IsSelected"/>.
/// </summary>
/// <param name="container">The container.</param>
/// <param name="selected">Whether the control is selected</param>
private void MarkContainerSelected(Control container, bool selected)
{
container.SetCurrentValue(SelectingItemsControl.IsSelectedProperty, selected);
}
/// <summary>
/// Makes a list of objects equal another (though doesn't preserve order).
/// </summary>

7
src/Avalonia.Controls/TreeViewItem.cs

@ -31,7 +31,7 @@ namespace Avalonia.Controls
/// Defines the <see cref="IsSelected"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsSelectedProperty =
ListBoxItem.IsSelectedProperty.AddOwner<TreeViewItem>();
SelectingItemsControl.IsSelectedProperty.AddOwner<TreeViewItem>();
/// <summary>
/// Defines the <see cref="Level"/> property.
@ -105,6 +105,11 @@ namespace Avalonia.Controls
EnsureTreeView().PrepareContainerForItemOverride(container, item, index);
}
protected internal override void ContainerForItemPreparedOverride(Control container, object? item, int index)
{
EnsureTreeView().ContainerForItemPreparedOverride(container, item, index);
}
/// <inheritdoc/>
protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
{

475
tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs

@ -1,17 +1,22 @@
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Styling;
using Avalonia.UnitTests;
using Avalonia.VisualTree;
using Moq;
using Xunit;
namespace Avalonia.Controls.UnitTests
{
public class ListBoxTests_Multiple
{
private MouseTestHelper _helper = new MouseTestHelper();
[Fact]
public void Focusing_Item_With_Shift_And_Arrow_Key_Should_Add_To_Selection()
{
@ -82,6 +87,468 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems);
}
[Fact]
public void Shift_Selecting_From_No_Selection_Selects_From_Start()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Shift);
Assert.Equal(new[] { "Foo", "Bar", "Baz" }, target.SelectedItems);
Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target));
}
}
[Fact]
public void Ctrl_Selecting_Raises_SelectionChanged_Events()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Qux" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
SelectionChangedEventArgs receivedArgs = null;
target.SelectionChanged += (_, args) => receivedArgs = args;
void VerifyAdded(string selection)
{
Assert.NotNull(receivedArgs);
Assert.Equal(new[] { selection }, receivedArgs.AddedItems);
Assert.Empty(receivedArgs.RemovedItems);
}
void VerifyRemoved(string selection)
{
Assert.NotNull(receivedArgs);
Assert.Equal(new[] { selection }, receivedArgs.RemovedItems);
Assert.Empty(receivedArgs.AddedItems);
}
_helper.Click(target.Presenter.Panel.Children[1]);
VerifyAdded("Bar");
receivedArgs = null;
_helper.Click(target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Control);
VerifyAdded("Baz");
receivedArgs = null;
_helper.Click(target.Presenter.Panel.Children[3], modifiers: KeyModifiers.Control);
VerifyAdded("Qux");
receivedArgs = null;
_helper.Click(target.Presenter.Panel.Children[1], modifiers: KeyModifiers.Control);
VerifyRemoved("Bar");
}
}
[Fact]
public void Ctrl_Selecting_SelectedItem_With_Multiple_Selection_Active_Sets_SelectedItem_To_Next_Selection()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Qux" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[1]);
_helper.Click(target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Control);
_helper.Click(target.Presenter.Panel.Children[3], modifiers: KeyModifiers.Control);
Assert.Equal(1, target.SelectedIndex);
Assert.Equal("Bar", target.SelectedItem);
Assert.Equal(new[] { "Bar", "Baz", "Qux" }, target.SelectedItems);
_helper.Click(target.Presenter.Panel.Children[1], modifiers: KeyModifiers.Control);
Assert.Equal(2, target.SelectedIndex);
Assert.Equal("Baz", target.SelectedItem);
Assert.Equal(new[] { "Baz", "Qux" }, target.SelectedItems);
}
}
[Fact]
public void Ctrl_Selecting_Non_SelectedItem_With_Multiple_Selection_Active_Leaves_SelectedItem_The_Same()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[1]);
_helper.Click(target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Control);
Assert.Equal(1, target.SelectedIndex);
Assert.Equal("Bar", target.SelectedItem);
_helper.Click(target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Control);
Assert.Equal(1, target.SelectedIndex);
Assert.Equal("Bar", target.SelectedItem);
}
}
[Fact]
public void Should_Ctrl_Select_Correct_Item_When_Duplicate_Items_Are_Present()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[3]);
_helper.Click(target.Presenter.Panel.Children[4], modifiers: KeyModifiers.Control);
var panel = target.Presenter.Panel;
Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems);
Assert.Equal(new[] { 3, 4 }, SelectedContainers(target));
}
}
[Fact]
public void Should_Shift_Select_Correct_Item_When_Duplicates_Are_Present()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[3]);
_helper.Click(target.Presenter.Panel.Children[5], modifiers: KeyModifiers.Shift);
var panel = target.Presenter.Panel;
Assert.Equal(new[] { "Foo", "Bar", "Baz" }, target.SelectedItems);
Assert.Equal(new[] { 3, 4, 5 }, SelectedContainers(target));
}
}
[Fact]
public void Can_Shift_Select_All_Items_When_Duplicates_Are_Present()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[0]);
_helper.Click(target.Presenter.Panel.Children[5], modifiers: KeyModifiers.Shift);
var panel = target.Presenter.Panel;
Assert.Equal(new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, target.SelectedItems);
Assert.Equal(new[] { 0, 1, 2, 3, 4, 5 }, SelectedContainers(target));
}
}
[Fact]
public void Shift_Selecting_Raises_SelectionChanged_Events()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Qux" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
SelectionChangedEventArgs receivedArgs = null;
target.SelectionChanged += (_, args) => receivedArgs = args;
void VerifyAdded(params string[] selection)
{
Assert.NotNull(receivedArgs);
Assert.Equal(selection, receivedArgs.AddedItems);
Assert.Empty(receivedArgs.RemovedItems);
}
void VerifyRemoved(string selection)
{
Assert.NotNull(receivedArgs);
Assert.Equal(new[] { selection }, receivedArgs.RemovedItems);
Assert.Empty(receivedArgs.AddedItems);
}
_helper.Click(target.Presenter.Panel.Children[1]);
VerifyAdded("Bar");
receivedArgs = null;
_helper.Click(target.Presenter.Panel.Children[3], modifiers: KeyModifiers.Shift);
VerifyAdded("Baz", "Qux");
receivedArgs = null;
_helper.Click(target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Shift);
VerifyRemoved("Qux");
}
}
[Fact]
public void Duplicate_Items_Are_Added_To_SelectedItems_In_Order()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[0]);
Assert.Equal(new[] { "Foo" }, target.SelectedItems);
_helper.Click(target.Presenter.Panel.Children[4], modifiers: KeyModifiers.Control);
Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems);
_helper.Click(target.Presenter.Panel.Children[3], modifiers: KeyModifiers.Control);
Assert.Equal(new[] { "Foo", "Bar", "Foo" }, target.SelectedItems);
_helper.Click(target.Presenter.Panel.Children[1], modifiers: KeyModifiers.Control);
Assert.Equal(new[] { "Foo", "Bar", "Foo", "Bar" }, target.SelectedItems);
}
}
[Fact]
public void Left_Click_On_SelectedItem_Should_Clear_Existing_Selection()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Width = 20, Height = 10 }),
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
target.SelectAll();
Assert.Equal(3, target.SelectedItems.Count);
_helper.Click(target.Presenter.Panel.Children[0]);
Assert.Equal(1, target.SelectedItems.Count);
Assert.Equal(new[] { "Foo", }, target.SelectedItems);
Assert.Equal(new[] { 0 }, SelectedContainers(target));
}
}
[Fact]
public void Right_Click_On_SelectedItem_Should_Not_Clear_Existing_Selection()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Width = 20, Height = 10 }),
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
target.SelectAll();
Assert.Equal(3, target.SelectedItems.Count);
_helper.Click(target.Presenter.Panel.Children[0], MouseButton.Right);
Assert.Equal(3, target.SelectedItems.Count);
}
}
[Fact]
public void Right_Click_On_UnselectedItem_Should_Clear_Existing_Selection()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Width = 20, Height = 10 }),
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[0]);
_helper.Click(target.Presenter.Panel.Children[1], modifiers: KeyModifiers.Shift);
Assert.Equal(2, target.SelectedItems.Count);
_helper.Click(target.Presenter.Panel.Children[2], MouseButton.Right);
Assert.Equal(1, target.SelectedItems.Count);
}
}
[Fact]
public void Shift_Right_Click_Should_Not_Select_Multiple()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Width = 20, Height = 10 }),
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[0]);
_helper.Click(target.Presenter.Panel.Children[2], MouseButton.Right, modifiers: KeyModifiers.Shift);
Assert.Equal(1, target.SelectedItems.Count);
}
}
[Fact]
public void Ctrl_Right_Click_Should_Not_Select_Multiple()
{
using (UnitTestApplication.Start())
{
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = new[] { "Foo", "Bar", "Baz" },
ItemTemplate = new FuncDataTemplate<string>((x, _) => new TextBlock { Width = 20, Height = 10 }),
SelectionMode = SelectionMode.Multiple,
Width = 100,
Height = 100,
};
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
AvaloniaLocator.CurrentMutable.Bind<PlatformHotkeyConfiguration>().ToConstant(new Mock<PlatformHotkeyConfiguration>().Object);
_helper.Click(target.Presenter.Panel.Children[0]);
_helper.Click(target.Presenter.Panel.Children[2], MouseButton.Right, modifiers: KeyModifiers.Control);
Assert.Equal(1, target.SelectedItems.Count);
}
}
private Control CreateListBoxTemplate(TemplatedControl parent, INameScope scope)
{
return new ScrollViewer
@ -119,5 +586,13 @@ namespace Avalonia.Controls.UnitTests
// Now the ItemsPresenter should be reigstered, so apply its template.
((Control)target.Presenter).ApplyTemplate();
}
private static IEnumerable<int> SelectedContainers(SelectingItemsControl target)
{
return target.Presenter.Panel.Children
.Select(x => x.Classes.Contains(":selected") ? target.IndexFromContainer(x) : -1)
.Where(x => x != -1);
}
}
}

7
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs

@ -2170,7 +2170,12 @@ namespace Avalonia.Controls.UnitTests.Primitives
private class Item : Control, ISelectable
{
public string Value { get; set; }
public bool IsSelected { get; set; }
public bool IsSelected
{
get => SelectingItemsControl.GetIsSelected(this);
set => SelectingItemsControl.SetIsSelected(this, value);
}
}
private class MasterViewModel : NotifyingBase

1
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs

@ -116,7 +116,6 @@ namespace Avalonia.Controls.UnitTests.Primitives
Assert.Equal(0, target.SelectedIndex);
Assert.Equal("bar", target.SelectedItem);
Assert.Equal(new[] { ":selected" }, target.Presenter.Panel.Children[0].Classes);
}
private static FuncControlTemplate Template()

1596
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs

File diff suppressed because it is too large

249
tests/Avalonia.Controls.UnitTests/TreeViewTests.cs

@ -5,6 +5,7 @@ using System.ComponentModel;
using System.Linq;
using Avalonia.Collections;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Data.Core;
@ -219,7 +220,7 @@ namespace Avalonia.Controls.UnitTests
Assert.True(fromContainer.IsSelected);
ClickContainer(toContainer, KeyModifiers.Shift);
AssertChildrenSelected(target, rootNode);
AssertAllChildContainersSelected(target, rootNode);
}
[Fact]
@ -238,7 +239,7 @@ namespace Avalonia.Controls.UnitTests
Assert.True(fromContainer.IsSelected);
ClickContainer(toContainer, KeyModifiers.Shift);
AssertChildrenSelected(target, rootNode);
AssertAllChildContainersSelected(target, rootNode);
}
[Fact]
@ -255,7 +256,7 @@ namespace Avalonia.Controls.UnitTests
ClickContainer(fromContainer, KeyModifiers.None);
ClickContainer(toContainer, KeyModifiers.Shift);
AssertChildrenSelected(target, rootNode);
AssertAllChildContainersSelected(target, rootNode);
ClickContainer(fromContainer, KeyModifiers.None);
Assert.True(fromContainer.IsSelected);
@ -975,7 +976,7 @@ namespace Avalonia.Controls.UnitTests
target.RaiseEvent(keyEvent);
AssertChildrenSelected(target, rootNode);
AssertAllChildContainersSelected(target, rootNode);
}
[Fact]
@ -1005,7 +1006,7 @@ namespace Avalonia.Controls.UnitTests
target.RaiseEvent(keyEvent);
AssertChildrenSelected(target, rootNode);
AssertAllChildContainersSelected(target, rootNode);
}
[Fact]
@ -1035,7 +1036,7 @@ namespace Avalonia.Controls.UnitTests
target.RaiseEvent(keyEvent);
AssertChildrenSelected(target, rootNode);
AssertAllChildContainersSelected(target, rootNode);
}
[Fact]
@ -1047,7 +1048,7 @@ namespace Avalonia.Controls.UnitTests
target.SelectAll();
AssertChildrenSelected(target, data[0]);
AssertAllChildContainersSelected(target, data[0]);
Assert.Equal(5, target.SelectedItems.Count);
_mouse.Click(target.Presenter!.Panel!.Children[0], MouseButton.Right);
@ -1259,6 +1260,174 @@ namespace Avalonia.Controls.UnitTests
}
}
[Fact]
public void Can_Bind_Initial_Selected_State_Via_ItemContainerTheme()
{
using var app = Start();
var data = CreateTestTreeData();
var selected = new[] { data[0], data[0].Children[1] };
foreach (var node in selected)
node.IsSelected = true;
var itemTheme = new ControlTheme(typeof(TreeViewItem))
{
BasedOn = CreateTreeViewItemControlTheme(),
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
var target = CreateTarget(data: data, itemContainerTheme: itemTheme, multiSelect: true);
AssertDataSelection(data, selected);
AssertContainerSelection(target, selected);
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(selected, target.SelectedItems);
}
[Fact]
public void Can_Bind_Initial_Selected_State_Via_Style()
{
using var app = Start();
var data = CreateTestTreeData();
var selected = new[] { data[0], data[0].Children[1] };
foreach (var node in selected)
node.IsSelected = true;
var style = new Style(x => x.OfType<TreeViewItem>())
{
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
var target = CreateTarget(data: data, multiSelect: true, styles: new[] { style });
AssertDataSelection(data, selected);
AssertContainerSelection(target, selected);
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(selected, target.SelectedItems);
}
[Fact]
public void Selection_State_Is_Updated_Via_IsSelected_Binding()
{
using var app = Start();
var data = CreateTestTreeData();
var selected = new[] { data[0], data[0].Children[1] };
selected[0].IsSelected = true;
var itemTheme = new ControlTheme(typeof(TreeViewItem))
{
BasedOn = CreateTreeViewItemControlTheme(),
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
var target = CreateTarget(data: data, itemContainerTheme: itemTheme, multiSelect: true);
selected[1].IsSelected = true;
AssertDataSelection(data, selected);
AssertContainerSelection(target, selected);
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(selected, target.SelectedItems);
}
[Fact]
public void Selection_State_Is_Updated_Via_IsSelected_Binding_On_Expand()
{
using var app = Start();
var data = CreateTestTreeData();
var selected = new[] { data[0], data[0].Children[1] };
foreach (var node in selected)
node.IsSelected = true;
var itemTheme = new ControlTheme(typeof(TreeViewItem))
{
BasedOn = CreateTreeViewItemControlTheme(),
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
var target = CreateTarget(
data: data,
expandAll: false,
itemContainerTheme: itemTheme,
multiSelect: true);
var rootContainer = Assert.IsType<TreeViewItem>(target.ContainerFromIndex(0));
// Root TreeViewItem isn't expanded so selection for child won't have been picked
// up by IsSelected binding yet.
AssertContainerSelection(target, new[] { selected[0] });
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(new[] { selected[0] }, target.SelectedItems);
rootContainer.IsExpanded = true;
Layout(target);
// Root is expanded so now all expected items will be selected.
AssertDataSelection(data, selected);
AssertContainerSelection(target, selected);
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(selected, target.SelectedItems);
}
[Fact]
public void Selection_State_Is_Updated_Via_IsSelected_Binding_On_Expand_Single_Select()
{
using var app = Start();
var data = CreateTestTreeData();
var selected = new[] { data[0], data[0].Children[1] };
foreach (var node in selected)
node.IsSelected = true;
var itemTheme = new ControlTheme(typeof(TreeViewItem))
{
BasedOn = CreateTreeViewItemControlTheme(),
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
var target = CreateTarget(
data: data,
expandAll: false,
itemContainerTheme: itemTheme);
var rootContainer = Assert.IsType<TreeViewItem>(target.ContainerFromIndex(0));
// Root TreeViewItem isn't expanded so selection for child won't have been picked
// up by IsSelected binding yet.
AssertContainerSelection(target, new[] { selected[0] });
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(new[] { selected[0] }, target.SelectedItems);
rootContainer.IsExpanded = true;
Layout(target);
// Root is expanded and newly revealed selected node will replace current selection
// given that we're in SelectionMode == Single.
selected = new[] { selected[1] };
AssertDataSelection(data, selected);
AssertContainerSelection(target, selected);
Assert.Equal(selected[0], target.SelectedItem);
Assert.Equal(selected, target.SelectedItems);
}
private static TreeView CreateTarget(Optional<IList<Node>?> data = default,
bool expandAll = true,
ControlTheme? itemContainerTheme = null,
@ -1465,17 +1634,61 @@ namespace Avalonia.Controls.UnitTests
_mouse.Click(container, modifiers: modifiers);
}
private void AssertChildrenSelected(TreeView treeView, Node rootNode)
private void AssertContainerSelection(TreeView treeView, params Node[] expected)
{
static void Evaluate(Control container, HashSet<Node> remaining)
{
var treeViewItem = Assert.IsType<TreeViewItem>(container);
var node = (Node)container.DataContext!;
Assert.Equal(remaining.Contains(node), treeViewItem.IsSelected);
remaining.Remove(node);
foreach (var child in treeViewItem.GetRealizedContainers())
{
Evaluate(child, remaining);
}
}
var remaining = expected.ToHashSet();
foreach (var container in treeView.GetRealizedContainers())
Evaluate(container, remaining);
Assert.Empty(remaining);
}
private void AssertAllChildContainersSelected(TreeView treeView, Node node)
{
Assert.NotNull(rootNode.Children);
Assert.NotNull(node.Children);
foreach (var child in rootNode.Children)
foreach (var child in node.Children)
{
var container = Assert.IsType<TreeViewItem>(treeView.TreeContainerFromItem(child));
Assert.True(container.IsSelected);
}
}
private void AssertDataSelection(IEnumerable<Node> data, params Node[] expected)
{
static void Evaluate(Node rootNode, HashSet<Node> remaining)
{
Assert.Equal(remaining.Contains(rootNode), rootNode.IsSelected);
remaining.Remove(rootNode);
if (rootNode.Children is null)
return;
foreach (var child in rootNode.Children)
{
Evaluate(child, remaining);
}
}
var remaining = expected.ToHashSet();
foreach (var node in data)
Evaluate(node, remaining);
Assert.Empty(remaining);
}
private IDisposable Start()
{
return UnitTestApplication.Start(
@ -1492,6 +1705,7 @@ namespace Avalonia.Controls.UnitTests
private class Node : NotifyingBase
{
private IAvaloniaList<Node> _children = new AvaloniaList<Node>();
private bool _isSelected;
public string? Value { get; set; }
@ -1504,6 +1718,21 @@ namespace Avalonia.Controls.UnitTests
RaisePropertyChanged(nameof(Children));
}
}
public bool IsSelected
{
get => _isSelected;
set
{
if (_isSelected != value)
{
_isSelected = value;
RaisePropertyChanged();
}
}
}
public override string ToString() => Value ?? string.Empty;
}
private class TestTreeDataTemplate : ITreeDataTemplate

Loading…
Cancel
Save