Skip to content

Commit

Permalink
feat: Implement multiple selection in the DynamicTree control
Browse files Browse the repository at this point in the history
  • Loading branch information
abdes committed Oct 4, 2024
1 parent 46fc366 commit 610f2aa
Show file tree
Hide file tree
Showing 12 changed files with 194 additions and 39 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<dnc:DynamicTree
Width="400"
HorizontalAlignment="Left"
SelectionMode="Single"
SelectionMode="Multiple"
ThumbnailTemplateSelector="{StaticResource ThumbnailTemplateSelector}"
ViewModel="{x:Bind ViewModel}" />
</Page>
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
DoubleTapped="ItemDoubleTapped"
IsDoubleTapEnabled="True"
IsTapEnabled="True"
Tapped="ItemTapped">
PointerPressed="ItemPointerClicked">
<lc:DynamicTreeItem
Collapse="OnCollapseTreeItem"
Expand="OnExpandTreeItem"
Expand Down
42 changes: 40 additions & 2 deletions projects/Controls/DynamicTree/src/DynamicTree/DynamicTree.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ namespace DroidNet.Controls;

using System.Diagnostics;
using DroidNet.Mvvm.Generators;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Windows.System;
using Windows.UI.Core;

/// <summary>
/// A control to display a tree as a list of expandable items.
Expand Down Expand Up @@ -65,14 +68,49 @@ public DataTemplateSelector ThumbnailTemplateSelector
set => this.SetValue(ThumbnailTemplateSelectorProperty, value);
}

private void ItemTapped(object sender, TappedRoutedEventArgs args)
private static bool IsControlKeyDown() => InputKeyboardSource
.GetKeyStateForCurrentThread(VirtualKey.Control)
.HasFlag(CoreVirtualKeyStates.Down);

private static bool IsShiftKeyDown() => InputKeyboardSource
.GetKeyStateForCurrentThread(VirtualKey.Shift)
.HasFlag(CoreVirtualKeyStates.Down);

private void ItemPointerClicked(object sender, PointerRoutedEventArgs args)
{
args.Handled = true;
if (sender is not FrameworkElement { DataContext: TreeItemAdapter item } element)
{
return;
}

if (sender is FrameworkElement { DataContext: TreeItemAdapter item })
// Get the current state of the pointer
var pointerPoint = args.GetCurrentPoint(element);

// Check if the pointer device is a mouse
// Check if the left mouse button is pressed
if (args.Pointer.PointerDeviceType != PointerDeviceType.Mouse || !pointerPoint.Properties.IsLeftButtonPressed)
{
return;
}

var coreWindow = CoreWindow.GetForCurrentThread();

if (IsControlKeyDown())
{
// Handle Ctrl+Click
this.ViewModel!.SelectItem(item);
}
else if (IsShiftKeyDown())
{
// Handle Shift+Click
this.ViewModel!.ExtendSelectionTo(item);
}
else
{
// Handle regular Click
this.ViewModel!.ClearAndSelectItem(item);
}
}

private void ItemDoubleTapped(object sender, DoubleTappedRoutedEventArgs args)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ public abstract partial class DynamicTreeViewModel : ObservableObject

public void SelectItem(TreeItemAdapter item) => this.selectionModel?.SelectItem(item);

public void ClearAndSelectItem(TreeItemAdapter item) => this.selectionModel?.ClearAndSelectItem(item);

public void ExtendSelectionTo(TreeItemAdapter item)
{
if (this.SelectionMode == SelectionMode.Multiple && this.selectionModel?.SelectedItem is not null)
{
((MultipleSelectionModel<ITreeItem>)this.selectionModel).SelectRange(
this.selectionModel.SelectedItem,
item);
}
else
{
this.selectionModel?.SelectItem(item);
}
}

protected async Task InitializeRootAsync(ITreeItem root)
{
this.ShownItems.Clear();
Expand All @@ -37,8 +53,8 @@ partial void OnSelectionModeChanged(SelectionMode value) =>
{
SelectionMode.None => default,
SelectionMode.Single => new SingleSelectionModel(this),
SelectionMode.Multiple => default, // TODO: support multiple selection
_ => throw new InvalidEnumArgumentException(nameof(value), (int)value, typeof(SelectionMode))
SelectionMode.Multiple => new MultipleSelectionModel(this),
_ => throw new InvalidEnumArgumentException(nameof(value), (int)value, typeof(SelectionMode)),
};

[RelayCommand]
Expand Down Expand Up @@ -158,4 +174,13 @@ public SingleSelectionModel(DynamicTreeViewModel model)

protected override int IndexOf(ITreeItem item) => this.model.ShownItems.IndexOf((TreeItemAdapter)item);
}

protected class MultipleSelectionModel(DynamicTreeViewModel model) : MultipleSelectionModel<ITreeItem>
{
protected override ITreeItem GetItemAt(int index) => model.ShownItems[index];

protected override int GetItemCount() => model.ShownItems.Count;

protected override int IndexOf(ITreeItem item) => model.ShownItems.IndexOf((TreeItemAdapter)item);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,14 @@ namespace DroidNet.Controls;
using System.Collections.ObjectModel;
using System.Collections.Specialized;

public interface ITreeItem
public interface ITreeItem : ISelectable
{
event EventHandler<NotifyCollectionChangedEventArgs> ChildrenCollectionChanged;

string Label { get; }

bool IsExpanded { get; set; }

bool IsSelected { get; set; }

bool IsRoot { get; }

ITreeItem? Parent { get; }
Expand Down
10 changes: 10 additions & 0 deletions projects/Controls/Helpers/src/Selection/ISelectable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Distributed under the MIT License. See accompanying file LICENSE or copy
// at https://opensource.org/licenses/MIT.
// SPDX-License-Identifier: MIT

namespace DroidNet.Controls;

public interface ISelectable
{
bool IsSelected { get; set; }
}
46 changes: 34 additions & 12 deletions projects/Controls/Helpers/src/Selection/MultipleSelectionModel`1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ namespace DroidNet.Controls;

public abstract class MultipleSelectionModel<T> : SelectionModel<T>
{
private readonly SelectionObservableCollection<int> selectedIndices;
private readonly SelectionObservableCollection<T> selectedIndices;

/// <summary>
/// Initializes a new instance of the <see cref="MultipleSelectionModel{T}" /> class.
/// </summary>
protected MultipleSelectionModel()
{
this.selectedIndices = new SelectionObservableCollection<int>(new HashSet<int>());
this.selectedIndices
= new SelectionObservableCollection<T>(new HashSet<int>())
{
GetItemAt = this.GetItemAt,
};
this.SelectedIndices = new ReadOnlyObservableCollection<int>(this.selectedIndices);
}

Expand Down Expand Up @@ -45,7 +49,7 @@ public ReadOnlyObservableCollection<T> SelectedItems
}

/// <inheritdoc />
public override void ClearAndSelect(int index)
public override void ClearAndSelectItemAt(int index)
{
this.ValidIndexOrThrow(index);

Expand Down Expand Up @@ -146,7 +150,7 @@ public override void SelectItemAt(int index)
/// selections - to do so it is necessary to first call <see cref="ClearSelection()" />.
/// </para>
/// <para>
/// The last valid index given will become the selected index / selected item.
/// The first valid index given will become the selected index / selected item.
/// </para>
/// </summary>
/// <param name="indices">
Expand Down Expand Up @@ -175,7 +179,7 @@ public void SelectItemsAt(params int[] indices)
{
this.ClearSelection();

var lastIndex = indices
_ = indices
.Where(index => index >= 0 && index < itemsCount)
.Select(
index =>
Expand All @@ -186,26 +190,27 @@ public void SelectItemsAt(params int[] indices)
.DefaultIfEmpty(-1)
.Last();

if (lastIndex != -1)
if (this.selectedIndices.Count != 0)
{
this.SetSelectedIndex(lastIndex);
this.SetSelectedIndex(this.selectedIndices[0]);
}
}
}

/// <summary>
/// Selects all indices from the given <paramref name="start" /> index to the item before the given <paramref name="end" />
/// index. This means that the selection is inclusive of the <paramref name="start" /> index, and exclusive of the <paramref name="end" /> index.
/// index. This means that the selection is inclusive of the <paramref name="start" /> index, and inclusive of the
/// <paramref name="end" /> index.
/// <para>
/// This method will work regardless of whether start &lt; end or start &gt; end: the only constant is that the index before
/// the given <paramref name="end" /> index will become the <see cref="SelectionModel{T}.SelectedIndex">SelectedIndex</see>.
/// This method will work regardless of whether start &lt; end or start &gt; end: the only constant is that the given
/// <paramref name="end" /> index will become the <see cref="SelectionModel{T}.SelectedIndex">SelectedIndex</see>.
/// </para>
/// </summary>
/// <param name="start">
/// The first index to select - this index will be selected.
/// </param>
/// <param name="end">
/// The last index of the selection - this index will not be selected.
/// The last index of the selection - this index will be selected.
/// </param>
/// <exception cref="ArgumentOutOfRangeException">
/// If the given <paramref name="start" /> or <paramref name="end" /> index is less than zero, or greater than or equal to the
Expand All @@ -220,7 +225,7 @@ public void SelectRange(int start, int end)
var low = ascending ? start : end;
var high = ascending ? end : start;

var arrayLength = high - low;
var arrayLength = high - low + 1;
var indices = new int[arrayLength];

var startValue = ascending ? low : high;
Expand All @@ -232,6 +237,23 @@ public void SelectRange(int start, int end)
this.SelectItemsAt(indices);
}

public void SelectRange(T start, T end)
{
var startIndex = this.IndexOf(start);
if (startIndex == -1)
{
throw new ArgumentException("item not in selected items collection", nameof(start));
}

var endIndex = this.IndexOf(end);
if (endIndex == -1)
{
throw new ArgumentException("item not in selected items collection", nameof(end));
}

this.SelectRange(startIndex, endIndex);
}

/// <summary>
/// Convenience method to select all available indices.
/// </summary>
Expand Down
24 changes: 23 additions & 1 deletion projects/Controls/Helpers/src/Selection/SelectionModel`1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ public abstract class SelectionModel<T> : INotifyPropertyChanging, INotifyProper
/// </remarks>
public abstract void ClearSelection(int index);

public void ClearSelection(T item)
{
var index = this.IndexOf(item);
if (index == -1)
{
throw new ArgumentException("item not in selected items collection", nameof(item));
}

this.ClearSelection(index);
}

/// <summary>
/// Clears any selection prior to setting the selection to the given index.
/// </summary>
Expand All @@ -109,7 +120,18 @@ public abstract class SelectionModel<T> : INotifyPropertyChanging, INotifyProper
/// If the given <paramref name="index" /> is less than zero, or greater than or equal to the total number of items in the
/// underlying data model).
/// </exception>
public abstract void ClearAndSelect(int index);
public abstract void ClearAndSelectItemAt(int index);

public virtual void ClearAndSelectItem(T item)
{
var index = this.IndexOf(item);
if (index == -1)
{
throw new ArgumentException("item not in selected items collection", nameof(item));
}

this.ClearAndSelectItemAt(index);
}

/// <summary>
/// Convenience method to inform if the given <paramref name="index" /> is currently selected in this SelectionModel.
Expand Down
Loading

0 comments on commit 610f2aa

Please sign in to comment.