csharpc-sharpdotnetxamlavaloniauicross-platformcross-platform-xamlavaloniaguimulti-platformuser-interfacedotnetcore
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1388 lines
48 KiB
1388 lines
48 KiB
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Specialized;
|
|
using System.ComponentModel;
|
|
using System.Linq;
|
|
using System.Xml.Linq;
|
|
using Avalonia.Controls.Generators;
|
|
using Avalonia.Controls.Selection;
|
|
using Avalonia.Controls.Utils;
|
|
using Avalonia.Data;
|
|
using Avalonia.Input;
|
|
using Avalonia.Input.Platform;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Metadata;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
|
|
namespace Avalonia.Controls.Primitives
|
|
{
|
|
/// <summary>
|
|
/// An <see cref="ItemsControl"/> that maintains a selection.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <see cref="SelectingItemsControl"/> provides a base class for <see cref="ItemsControl"/>s
|
|
/// that maintain a selection (single or multiple). By default only its
|
|
/// <see cref="SelectedIndex"/> and <see cref="SelectedItem"/> properties are visible; the
|
|
/// current multiple <see cref="Selection"/> and <see cref="SelectedItems"/> together with the
|
|
/// <see cref="SelectionMode"/> properties are protected, however a derived class can expose
|
|
/// these if it wishes to support multiple selection.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="SelectingItemsControl"/> maintains a selection respecting the current
|
|
/// <see cref="SelectionMode"/> but it does not react to user input; this must be handled in a
|
|
/// derived class. It does, however, respond to <see cref="IsSelectedChangedEvent"/> events
|
|
/// from items and updates the selection accordingly.
|
|
/// </para>
|
|
/// </remarks>
|
|
public class SelectingItemsControl : ItemsControl
|
|
{
|
|
/// <summary>
|
|
/// Defines the <see cref="AutoScrollToSelectedItem"/> property.
|
|
/// </summary>
|
|
public static readonly StyledProperty<bool> AutoScrollToSelectedItemProperty =
|
|
AvaloniaProperty.Register<SelectingItemsControl, bool>(
|
|
nameof(AutoScrollToSelectedItem),
|
|
defaultValue: true);
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="SelectedIndex"/> property.
|
|
/// </summary>
|
|
public static readonly DirectProperty<SelectingItemsControl, int> SelectedIndexProperty =
|
|
AvaloniaProperty.RegisterDirect<SelectingItemsControl, int>(
|
|
nameof(SelectedIndex),
|
|
o => o.SelectedIndex,
|
|
(o, v) => o.SelectedIndex = v,
|
|
unsetValue: -1,
|
|
defaultBindingMode: BindingMode.TwoWay);
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="SelectedItem"/> property.
|
|
/// </summary>
|
|
public static readonly DirectProperty<SelectingItemsControl, object?> SelectedItemProperty =
|
|
AvaloniaProperty.RegisterDirect<SelectingItemsControl, object?>(
|
|
nameof(SelectedItem),
|
|
o => o.SelectedItem,
|
|
(o, v) => o.SelectedItem = v,
|
|
defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true);
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="SelectedValue"/> property
|
|
/// </summary>
|
|
public static readonly StyledProperty<object?> SelectedValueProperty =
|
|
AvaloniaProperty.Register<SelectingItemsControl, object?>(nameof(SelectedValue),
|
|
defaultBindingMode: BindingMode.TwoWay);
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="SelectedValueBinding"/> property
|
|
/// </summary>
|
|
public static readonly StyledProperty<IBinding?> SelectedValueBindingProperty =
|
|
AvaloniaProperty.Register<SelectingItemsControl, IBinding?>(nameof(SelectedValueBinding));
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="SelectedItems"/> property.
|
|
/// </summary>
|
|
protected static readonly DirectProperty<SelectingItemsControl, IList?> SelectedItemsProperty =
|
|
AvaloniaProperty.RegisterDirect<SelectingItemsControl, IList?>(
|
|
nameof(SelectedItems),
|
|
o => o.SelectedItems,
|
|
(o, v) => o.SelectedItems = v);
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="Selection"/> property.
|
|
/// </summary>
|
|
protected static readonly DirectProperty<SelectingItemsControl, ISelectionModel> SelectionProperty =
|
|
AvaloniaProperty.RegisterDirect<SelectingItemsControl, ISelectionModel>(
|
|
nameof(Selection),
|
|
o => o.Selection,
|
|
(o, v) => o.Selection = v);
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="SelectionMode"/> property.
|
|
/// </summary>
|
|
protected static readonly StyledProperty<SelectionMode> SelectionModeProperty =
|
|
AvaloniaProperty.Register<SelectingItemsControl, SelectionMode>(
|
|
nameof(SelectionMode));
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="IsTextSearchEnabled"/> property.
|
|
/// </summary>
|
|
public static readonly StyledProperty<bool> IsTextSearchEnabledProperty =
|
|
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.
|
|
/// </summary>
|
|
public static readonly RoutedEvent<RoutedEventArgs> IsSelectedChangedEvent =
|
|
RoutedEvent.Register<SelectingItemsControl, RoutedEventArgs>(
|
|
"IsSelectedChanged",
|
|
RoutingStrategies.Bubble);
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="SelectionChanged"/> event.
|
|
/// </summary>
|
|
public static readonly RoutedEvent<SelectionChangedEventArgs> SelectionChangedEvent =
|
|
RoutedEvent.Register<SelectingItemsControl, SelectionChangedEventArgs>(
|
|
nameof(SelectionChanged),
|
|
RoutingStrategies.Bubble);
|
|
|
|
/// <summary>
|
|
/// Defines the <see cref="WrapSelection"/> property.
|
|
/// </summary>
|
|
public static readonly StyledProperty<bool> WrapSelectionProperty =
|
|
AvaloniaProperty.Register<SelectingItemsControl, bool>(nameof(WrapSelection), defaultValue: false);
|
|
private string _textSearchTerm = string.Empty;
|
|
private DispatcherTimer? _textSearchTimer;
|
|
private ISelectionModel? _selection;
|
|
private int _oldSelectedIndex;
|
|
private object? _oldSelectedItem;
|
|
private IList? _oldSelectedItems;
|
|
private bool _ignoreContainerSelectionChanged;
|
|
private UpdateState? _updateState;
|
|
private bool _hasScrolledToSelectedItem;
|
|
private BindingHelper? _bindingHelper;
|
|
private bool _isSelectionChangeActive;
|
|
|
|
/// <summary>
|
|
/// Initializes static members of the <see cref="SelectingItemsControl"/> class.
|
|
/// </summary>
|
|
static SelectingItemsControl()
|
|
{
|
|
IsSelectedChangedEvent.AddClassHandler<SelectingItemsControl>((x, e) => x.ContainerSelectionChanged(e));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Occurs when the control's selection changes.
|
|
/// </summary>
|
|
public event EventHandler<SelectionChangedEventArgs>? SelectionChanged
|
|
{
|
|
add { AddHandler(SelectionChangedEvent, value); }
|
|
remove { RemoveHandler(SelectionChangedEvent, value); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value indicating whether to automatically scroll to newly selected items.
|
|
/// </summary>
|
|
public bool AutoScrollToSelectedItem
|
|
{
|
|
get { return GetValue(AutoScrollToSelectedItemProperty); }
|
|
set { SetValue(AutoScrollToSelectedItemProperty, value); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the index of the selected item.
|
|
/// </summary>
|
|
public int SelectedIndex
|
|
{
|
|
get
|
|
{
|
|
// When a Begin/EndInit/DataContext update is in place we return the value to be
|
|
// updated here, even though it's not yet active and the property changed notification
|
|
// has not yet been raised. If we don't do this then the old value will be written back
|
|
// to the source when two-way bound, and the update value will be lost.
|
|
return _updateState?.SelectedIndex.HasValue == true ?
|
|
_updateState.SelectedIndex.Value :
|
|
Selection.SelectedIndex;
|
|
}
|
|
set
|
|
{
|
|
if (_updateState is object)
|
|
{
|
|
_updateState.SelectedIndex = value;
|
|
}
|
|
else
|
|
{
|
|
Selection.SelectedIndex = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the selected item.
|
|
/// </summary>
|
|
public object? SelectedItem
|
|
{
|
|
get
|
|
{
|
|
// See SelectedIndex setter for more information.
|
|
return _updateState?.SelectedItem.HasValue == true ?
|
|
_updateState.SelectedItem.Value :
|
|
Selection.SelectedItem;
|
|
}
|
|
set
|
|
{
|
|
if (_updateState is object)
|
|
{
|
|
_updateState.SelectedItem = value;
|
|
}
|
|
else
|
|
{
|
|
Selection.SelectedItem = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the <see cref="IBinding"/> instance used to obtain the
|
|
/// <see cref="SelectedValue"/> property
|
|
/// </summary>
|
|
[AssignBinding]
|
|
[InheritDataTypeFromItems(nameof(Items))]
|
|
public IBinding? SelectedValueBinding
|
|
{
|
|
get => GetValue(SelectedValueBindingProperty);
|
|
set => SetValue(SelectedValueBindingProperty, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the value of the selected item, obtained using
|
|
/// <see cref="SelectedValueBinding"/>
|
|
/// </summary>
|
|
public object? SelectedValue
|
|
{
|
|
get => GetValue(SelectedValueProperty);
|
|
set => SetValue(SelectedValueProperty, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the selected items.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// By default returns a collection that can be modified in order to manipulate the control
|
|
/// selection, however this property will return null if <see cref="Selection"/> is
|
|
/// re-assigned; you should only use _either_ Selection or SelectedItems.
|
|
/// </remarks>
|
|
protected IList? SelectedItems
|
|
{
|
|
get
|
|
{
|
|
// See SelectedIndex setter for more information.
|
|
if (_updateState?.SelectedItems.HasValue == true)
|
|
{
|
|
return _updateState.SelectedItems.Value;
|
|
}
|
|
else if (Selection is InternalSelectionModel ism)
|
|
{
|
|
var result = ism.WritableSelectedItems;
|
|
_oldSelectedItems = result;
|
|
return result;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
set
|
|
{
|
|
if (_updateState is object)
|
|
{
|
|
_updateState.SelectedItems = new Optional<IList?>(value);
|
|
}
|
|
else if (Selection is InternalSelectionModel i)
|
|
{
|
|
i.WritableSelectedItems = value;
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException("Cannot set both Selection and SelectedItems.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the model that holds the current selection.
|
|
/// </summary>
|
|
protected ISelectionModel Selection
|
|
{
|
|
get
|
|
{
|
|
if (_updateState?.Selection.HasValue == true)
|
|
{
|
|
return _updateState.Selection.Value;
|
|
}
|
|
else
|
|
{
|
|
if (_selection is null)
|
|
{
|
|
_selection = CreateDefaultSelectionModel();
|
|
InitializeSelectionModel(_selection);
|
|
}
|
|
|
|
return _selection;
|
|
}
|
|
}
|
|
set
|
|
{
|
|
value ??= CreateDefaultSelectionModel();
|
|
|
|
if (_updateState is object)
|
|
{
|
|
_updateState.Selection = new Optional<ISelectionModel>(value);
|
|
}
|
|
else if (_selection != value)
|
|
{
|
|
if (value.Source != null && value.Source != Items)
|
|
{
|
|
throw new ArgumentException(
|
|
"The supplied ISelectionModel already has an assigned Source but this " +
|
|
"collection is different to the Items on the control.");
|
|
}
|
|
|
|
var oldSelection = _selection?.SelectedItems.ToArray();
|
|
DeinitializeSelectionModel(_selection);
|
|
_selection = value;
|
|
|
|
if (oldSelection?.Length > 0)
|
|
{
|
|
RaiseEvent(new SelectionChangedEventArgs(
|
|
SelectionChangedEvent,
|
|
oldSelection,
|
|
Array.Empty<object>()));
|
|
}
|
|
|
|
InitializeSelectionModel(_selection);
|
|
|
|
if (_oldSelectedItems != SelectedItems)
|
|
{
|
|
RaisePropertyChanged(
|
|
SelectedItemsProperty,
|
|
new Optional<IList?>(_oldSelectedItems),
|
|
new BindingValue<IList?>(SelectedItems));
|
|
_oldSelectedItems = SelectedItems;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value that specifies whether a user can jump to a value by typing.
|
|
/// </summary>
|
|
public bool IsTextSearchEnabled
|
|
{
|
|
get { return GetValue(IsTextSearchEnabledProperty); }
|
|
set { SetValue(IsTextSearchEnabledProperty, value); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value which indicates whether to wrap around when the first
|
|
/// or last item is reached.
|
|
/// </summary>
|
|
public bool WrapSelection
|
|
{
|
|
get { return GetValue(WrapSelectionProperty); }
|
|
set { SetValue(WrapSelectionProperty, value); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the selection mode.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Note that the selection mode only applies to selections made via user interaction.
|
|
/// Multiple selections can be made programmatically regardless of the value of this property.
|
|
/// </remarks>
|
|
protected SelectionMode SelectionMode
|
|
{
|
|
get { return GetValue(SelectionModeProperty); }
|
|
set { SetValue(SelectionModeProperty, value); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether <see cref="SelectionMode.AlwaysSelected"/> is set.
|
|
/// </summary>
|
|
protected bool AlwaysSelected => SelectionMode.HasAllFlags(SelectionMode.AlwaysSelected);
|
|
|
|
/// <inheritdoc/>
|
|
public override void BeginInit()
|
|
{
|
|
base.BeginInit();
|
|
BeginUpdating();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override void EndInit()
|
|
{
|
|
base.EndInit();
|
|
EndUpdating();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scrolls the specified item into view.
|
|
/// </summary>
|
|
/// <param name="index">The index of the item.</param>
|
|
public void ScrollIntoView(int index) => Presenter?.ScrollIntoView(index);
|
|
|
|
/// <summary>
|
|
/// Scrolls the specified item into view.
|
|
/// </summary>
|
|
/// <param name="item">The item.</param>
|
|
public void ScrollIntoView(object item) => ScrollIntoView(ItemsView.IndexOf(item));
|
|
|
|
/// <summary>
|
|
/// Tries to get the container that was the source of an event.
|
|
/// </summary>
|
|
/// <param name="eventSource">The control that raised the event.</param>
|
|
/// <returns>The container or null if the event did not originate in a container.</returns>
|
|
protected Control? GetContainerFromEventSource(object? eventSource)
|
|
{
|
|
for (var current = eventSource as Visual; current != null; current = current.VisualParent)
|
|
{
|
|
if (current is Control control && control.Parent == this &&
|
|
IndexFromContainer(control) != -1)
|
|
{
|
|
return control;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected override void ItemsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
|
{
|
|
base.ItemsCollectionChanged(sender!, e);
|
|
|
|
if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0)
|
|
{
|
|
SelectedIndex = 0;
|
|
}
|
|
}
|
|
|
|
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
|
{
|
|
base.OnAttachedToVisualTree(e);
|
|
AutoScrollToSelectedItemIfNecessary();
|
|
}
|
|
|
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
|
{
|
|
base.OnApplyTemplate(e);
|
|
|
|
void ExecuteScrollWhenLayoutUpdated(object? sender, EventArgs e)
|
|
{
|
|
LayoutUpdated -= ExecuteScrollWhenLayoutUpdated;
|
|
AutoScrollToSelectedItemIfNecessary();
|
|
}
|
|
|
|
if (AutoScrollToSelectedItem)
|
|
{
|
|
LayoutUpdated += ExecuteScrollWhenLayoutUpdated;
|
|
}
|
|
}
|
|
|
|
protected internal override void PrepareContainerForItemOverride(Control element, object? item, int index)
|
|
{
|
|
base.PrepareContainerForItemOverride(element, item, index);
|
|
|
|
if ((element as ISelectable)?.IsSelected == true)
|
|
{
|
|
Selection.Select(index);
|
|
MarkContainerSelected(element, true);
|
|
}
|
|
else
|
|
{
|
|
var selected = Selection.IsSelected(index);
|
|
MarkContainerSelected(element, selected);
|
|
}
|
|
}
|
|
|
|
protected override void ContainerIndexChangedOverride(Control container, int oldIndex, int newIndex)
|
|
{
|
|
base.ContainerIndexChangedOverride(container, oldIndex, newIndex);
|
|
MarkContainerSelected(container, Selection.IsSelected(newIndex));
|
|
}
|
|
|
|
protected internal override void ClearContainerForItemOverride(Control element)
|
|
{
|
|
base.ClearContainerForItemOverride(element);
|
|
|
|
if (Presenter?.Panel is InputElement panel &&
|
|
KeyboardNavigation.GetTabOnceActiveElement(panel) == element)
|
|
{
|
|
KeyboardNavigation.SetTabOnceActiveElement(panel, null);
|
|
}
|
|
|
|
if (element is ISelectable selectable)
|
|
MarkContainerSelected(element, false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override void OnDataContextBeginUpdate()
|
|
{
|
|
base.OnDataContextBeginUpdate();
|
|
BeginUpdating();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override void OnDataContextEndUpdate()
|
|
{
|
|
base.OnDataContextEndUpdate();
|
|
EndUpdating();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called to update the validation state for properties for which data validation is
|
|
/// enabled.
|
|
/// </summary>
|
|
/// <param name="property">The property.</param>
|
|
/// <param name="state">The current data binding state.</param>
|
|
/// <param name="error">The current data binding error, if any.</param>
|
|
protected override void UpdateDataValidation(
|
|
AvaloniaProperty property,
|
|
BindingValueType state,
|
|
Exception? error)
|
|
{
|
|
if (property == SelectedItemProperty)
|
|
{
|
|
DataValidationErrors.SetError(this, error);
|
|
}
|
|
}
|
|
|
|
protected override void OnInitialized()
|
|
{
|
|
base.OnInitialized();
|
|
|
|
if (_selection is object)
|
|
{
|
|
_selection.Source = Items;
|
|
}
|
|
}
|
|
|
|
protected override void OnTextInput(TextInputEventArgs e)
|
|
{
|
|
if (!e.Handled)
|
|
{
|
|
if (!IsTextSearchEnabled)
|
|
return;
|
|
|
|
StopTextSearchTimer();
|
|
|
|
_textSearchTerm += e.Text;
|
|
|
|
bool Match(Control container)
|
|
{
|
|
if (container is AvaloniaObject ao && ao.IsSet(TextSearch.TextProperty))
|
|
{
|
|
var searchText = ao.GetValue(TextSearch.TextProperty);
|
|
|
|
if (searchText?.StartsWith(_textSearchTerm, StringComparison.OrdinalIgnoreCase) == true)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return container is IContentControl control &&
|
|
control.Content?.ToString()?.StartsWith(_textSearchTerm, StringComparison.OrdinalIgnoreCase) == true;
|
|
}
|
|
|
|
var container = GetRealizedContainers().FirstOrDefault(Match);
|
|
|
|
if (container != null)
|
|
{
|
|
SelectedIndex = IndexFromContainer(container);
|
|
}
|
|
|
|
StartTextSearchTimer();
|
|
|
|
e.Handled = true;
|
|
}
|
|
|
|
base.OnTextInput(e);
|
|
}
|
|
|
|
protected override void OnKeyDown(KeyEventArgs e)
|
|
{
|
|
base.OnKeyDown(e);
|
|
|
|
if (!e.Handled)
|
|
{
|
|
var keymap = AvaloniaLocator.Current.GetService<PlatformHotkeyConfiguration>();
|
|
|
|
if (keymap is null)
|
|
return;
|
|
|
|
bool Match(List<KeyGesture> gestures) => gestures.Any(g => g.Matches(e));
|
|
|
|
if (ItemCount > 0 &&
|
|
Match(keymap.SelectAll) &&
|
|
SelectionMode.HasAllFlags(SelectionMode.Multiple))
|
|
{
|
|
Selection.SelectAll();
|
|
e.Handled = true;
|
|
}
|
|
else if (e.Key == Key.Space || e.Key == Key.Enter)
|
|
{
|
|
UpdateSelectionFromEventSource(
|
|
e.Source,
|
|
true,
|
|
e.KeyModifiers.HasFlag(KeyModifiers.Shift),
|
|
e.KeyModifiers.HasFlag(KeyModifiers.Control));
|
|
}
|
|
}
|
|
}
|
|
|
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
|
{
|
|
base.OnPropertyChanged(change);
|
|
|
|
if (change.Property == AutoScrollToSelectedItemProperty)
|
|
{
|
|
AutoScrollToSelectedItemIfNecessary();
|
|
}
|
|
if (change.Property == ItemsProperty && _updateState is null && _selection is object)
|
|
{
|
|
var newValue = change.GetNewValue<IEnumerable>();
|
|
_selection.Source = newValue;
|
|
|
|
if (newValue is null)
|
|
{
|
|
_selection.Clear();
|
|
}
|
|
}
|
|
else if (change.Property == SelectionModeProperty && _selection is object)
|
|
{
|
|
var newValue = change.GetNewValue<SelectionMode>();
|
|
_selection.SingleSelect = !newValue.HasAllFlags(SelectionMode.Multiple);
|
|
}
|
|
else if (change.Property == WrapSelectionProperty)
|
|
{
|
|
WrapFocus = WrapSelection;
|
|
}
|
|
else if (change.Property == SelectedValueProperty)
|
|
{
|
|
if (_isSelectionChangeActive)
|
|
return;
|
|
|
|
if (_updateState is not null)
|
|
{
|
|
_updateState.SelectedValue = change.NewValue;
|
|
return;
|
|
}
|
|
|
|
SelectItemWithValue(change.NewValue);
|
|
}
|
|
else if (change.Property == SelectedValueBindingProperty)
|
|
{
|
|
var idx = SelectedIndex;
|
|
|
|
// If no selection is active, don't do anything as SelectedValue is already null
|
|
if (idx == -1)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var value = change.GetNewValue<IBinding>();
|
|
if (value is null)
|
|
{
|
|
// Clearing SelectedValueBinding makes the SelectedValue the item itself
|
|
SelectedValue = SelectedItem;
|
|
return;
|
|
}
|
|
|
|
var selectedItem = SelectedItem;
|
|
|
|
try
|
|
{
|
|
_isSelectionChangeActive = true;
|
|
|
|
if (_bindingHelper is null)
|
|
{
|
|
_bindingHelper = new BindingHelper(value);
|
|
}
|
|
else
|
|
{
|
|
_bindingHelper.UpdateBinding(value);
|
|
}
|
|
|
|
// Re-evaluate SelectedValue with the new binding
|
|
SelectedValue = _bindingHelper.Evaluate(selectedItem);
|
|
}
|
|
finally
|
|
{
|
|
_isSelectionChangeActive = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves the selection in the specified direction relative to the current selection.
|
|
/// </summary>
|
|
/// <param name="direction">The direction to move.</param>
|
|
/// <param name="wrap">Whether to wrap when the selection reaches the first or last item.</param>
|
|
/// <returns>True if the selection was moved; otherwise false.</returns>
|
|
protected bool MoveSelection(NavigationDirection direction, bool wrap)
|
|
{
|
|
var from = SelectedIndex != -1 ? ContainerFromIndex(SelectedIndex) : null;
|
|
return MoveSelection(from, direction, wrap);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves the selection in the specified direction relative to the specified container.
|
|
/// </summary>
|
|
/// <param name="from">The container which serves as a starting point for the movement.</param>
|
|
/// <param name="direction">The direction to move.</param>
|
|
/// <param name="wrap">Whether to wrap when the selection reaches the first or last item.</param>
|
|
/// <returns>True if the selection was moved; otherwise false.</returns>
|
|
protected bool MoveSelection(Control? from, NavigationDirection direction, bool wrap)
|
|
{
|
|
if (Presenter?.Panel is INavigableContainer container &&
|
|
GetNextControl(container, direction, from, wrap) is Control next)
|
|
{
|
|
var index = IndexFromContainer(next);
|
|
|
|
if (index != -1)
|
|
{
|
|
SelectedIndex = index;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the selection for an item based on user interaction.
|
|
/// </summary>
|
|
/// <param name="index">The index of the item.</param>
|
|
/// <param name="select">Whether the item should be selected or unselected.</param>
|
|
/// <param name="rangeModifier">Whether the range modifier is enabled (i.e. shift key).</param>
|
|
/// <param name="toggleModifier">Whether the toggle modifier is enabled (i.e. ctrl key).</param>
|
|
/// <param name="rightButton">Whether the event is a right-click.</param>
|
|
/// <param name="fromFocus">Wheter the event is a focus event</param>
|
|
protected void UpdateSelection(
|
|
int index,
|
|
bool select = true,
|
|
bool rangeModifier = false,
|
|
bool toggleModifier = false,
|
|
bool rightButton = false,
|
|
bool fromFocus = false)
|
|
{
|
|
if (index < 0 || index >= ItemCount)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var mode = SelectionMode;
|
|
var multi = mode.HasAllFlags(SelectionMode.Multiple);
|
|
var toggle = toggleModifier || mode.HasAllFlags(SelectionMode.Toggle);
|
|
var range = multi && rangeModifier;
|
|
|
|
if (!select)
|
|
{
|
|
Selection.Deselect(index);
|
|
}
|
|
else if (rightButton)
|
|
{
|
|
if (Selection.IsSelected(index) == false)
|
|
{
|
|
SelectedIndex = index;
|
|
}
|
|
}
|
|
else if (range)
|
|
{
|
|
using var operation = Selection.BatchUpdate();
|
|
Selection.Clear();
|
|
Selection.SelectRange(Selection.AnchorIndex, index);
|
|
}
|
|
else if (!fromFocus && toggle)
|
|
{
|
|
if (multi)
|
|
{
|
|
if (Selection.IsSelected(index) == true)
|
|
{
|
|
Selection.Deselect(index);
|
|
}
|
|
else
|
|
{
|
|
Selection.Select(index);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
SelectedIndex = (SelectedIndex == index) ? -1 : index;
|
|
}
|
|
}
|
|
else if (!toggle)
|
|
{
|
|
using var operation = Selection.BatchUpdate();
|
|
Selection.Clear();
|
|
Selection.Select(index);
|
|
}
|
|
|
|
if (Presenter?.Panel != null)
|
|
{
|
|
var container = ContainerFromIndex(index);
|
|
KeyboardNavigation.SetTabOnceActiveElement(
|
|
(InputElement)Presenter.Panel,
|
|
container);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the selection for a container based on user interaction.
|
|
/// </summary>
|
|
/// <param name="container">The container.</param>
|
|
/// <param name="select">Whether the container should be selected or unselected.</param>
|
|
/// <param name="rangeModifier">Whether the range modifier is enabled (i.e. shift key).</param>
|
|
/// <param name="toggleModifier">Whether the toggle modifier is enabled (i.e. ctrl key).</param>
|
|
/// <param name="rightButton">Whether the event is a right-click.</param>
|
|
/// <param name="fromFocus">Wheter the event is a focus event</param>
|
|
protected void UpdateSelection(
|
|
Control container,
|
|
bool select = true,
|
|
bool rangeModifier = false,
|
|
bool toggleModifier = false,
|
|
bool rightButton = false,
|
|
bool fromFocus = false)
|
|
{
|
|
var index = IndexFromContainer(container);
|
|
|
|
if (index != -1)
|
|
{
|
|
UpdateSelection(index, select, rangeModifier, toggleModifier, rightButton, fromFocus);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the selection based on an event that may have originated in a container that
|
|
/// belongs to the control.
|
|
/// </summary>
|
|
/// <param name="eventSource">The control that raised the event.</param>
|
|
/// <param name="select">Whether the container should be selected or unselected.</param>
|
|
/// <param name="rangeModifier">Whether the range modifier is enabled (i.e. shift key).</param>
|
|
/// <param name="toggleModifier">Whether the toggle modifier is enabled (i.e. ctrl key).</param>
|
|
/// <param name="rightButton">Whether the event is a right-click.</param>
|
|
/// <param name="fromFocus">Wheter the event is a focus event</param>
|
|
/// <returns>
|
|
/// True if the event originated from a container that belongs to the control; otherwise
|
|
/// false.
|
|
/// </returns>
|
|
protected bool UpdateSelectionFromEventSource(
|
|
object? eventSource,
|
|
bool select = true,
|
|
bool rangeModifier = false,
|
|
bool toggleModifier = false,
|
|
bool rightButton = false,
|
|
bool fromFocus = false)
|
|
{
|
|
var container = GetContainerFromEventSource(eventSource);
|
|
|
|
if (container != null)
|
|
{
|
|
UpdateSelection(container, select, rangeModifier, toggleModifier, rightButton, fromFocus);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when <see cref="INotifyPropertyChanged.PropertyChanged"/> is raised on
|
|
/// <see cref="Selection"/>.
|
|
/// </summary>
|
|
/// <param name="sender">The sender.</param>
|
|
/// <param name="e">The event args.</param>
|
|
private void OnSelectionModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName == nameof(ISelectionModel.AnchorIndex))
|
|
{
|
|
_hasScrolledToSelectedItem = false;
|
|
AutoScrollToSelectedItemIfNecessary();
|
|
}
|
|
else if (e.PropertyName == nameof(ISelectionModel.SelectedIndex) && _oldSelectedIndex != SelectedIndex)
|
|
{
|
|
RaisePropertyChanged(SelectedIndexProperty, _oldSelectedIndex, SelectedIndex);
|
|
_oldSelectedIndex = SelectedIndex;
|
|
}
|
|
else if (e.PropertyName == nameof(ISelectionModel.SelectedItem) && _oldSelectedItem != SelectedItem)
|
|
{
|
|
RaisePropertyChanged(SelectedItemProperty, _oldSelectedItem, SelectedItem);
|
|
_oldSelectedItem = SelectedItem;
|
|
}
|
|
else if (e.PropertyName == nameof(InternalSelectionModel.WritableSelectedItems) &&
|
|
_oldSelectedItems != (Selection as InternalSelectionModel)?.SelectedItems)
|
|
{
|
|
RaisePropertyChanged(
|
|
SelectedItemsProperty,
|
|
new Optional<IList?>(_oldSelectedItems),
|
|
new BindingValue<IList?>(SelectedItems));
|
|
_oldSelectedItems = SelectedItems;
|
|
}
|
|
else if (e.PropertyName == nameof(ISelectionModel.Source))
|
|
{
|
|
ClearValue(SelectedValueProperty);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when <see cref="ISelectionModel.SelectionChanged"/> event is raised on
|
|
/// <see cref="Selection"/>.
|
|
/// </summary>
|
|
/// <param name="sender">The sender.</param>
|
|
/// <param name="e">The event args.</param>
|
|
private void OnSelectionModelSelectionChanged(object? sender, SelectionModelSelectionChangedEventArgs e)
|
|
{
|
|
void Mark(int index, bool selected)
|
|
{
|
|
var container = ContainerFromIndex(index);
|
|
|
|
if (container != null)
|
|
{
|
|
MarkContainerSelected(container, selected);
|
|
}
|
|
}
|
|
|
|
foreach (var i in e.SelectedIndexes)
|
|
{
|
|
Mark(i, true);
|
|
}
|
|
|
|
foreach (var i in e.DeselectedIndexes)
|
|
{
|
|
Mark(i, false);
|
|
}
|
|
|
|
if (!_isSelectionChangeActive)
|
|
{
|
|
UpdateSelectedValueFromItem();
|
|
}
|
|
|
|
var route = BuildEventRoute(SelectionChangedEvent);
|
|
|
|
if (route.HasHandlers)
|
|
{
|
|
var ev = new SelectionChangedEventArgs(
|
|
SelectionChangedEvent,
|
|
e.DeselectedItems.ToArray(),
|
|
e.SelectedItems.ToArray());
|
|
RaiseEvent(ev);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when <see cref="ISelectionModel.LostSelection"/> event is raised on
|
|
/// <see cref="Selection"/>.
|
|
/// </summary>
|
|
/// <param name="sender">The sender.</param>
|
|
/// <param name="e">The event args.</param>
|
|
private void OnSelectionModelLostSelection(object? sender, EventArgs e)
|
|
{
|
|
if (AlwaysSelected && Items is object)
|
|
{
|
|
SelectedIndex = 0;
|
|
}
|
|
}
|
|
|
|
private void SelectItemWithValue(object? value)
|
|
{
|
|
if (ItemCount == 0 || _isSelectionChangeActive)
|
|
return;
|
|
|
|
try
|
|
{
|
|
_isSelectionChangeActive = true;
|
|
var si = FindItemWithValue(value);
|
|
if (si != AvaloniaProperty.UnsetValue)
|
|
{
|
|
SelectedItem = si;
|
|
}
|
|
else
|
|
{
|
|
SelectedItem = null;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_isSelectionChangeActive = false;
|
|
}
|
|
}
|
|
|
|
private object FindItemWithValue(object? value)
|
|
{
|
|
if (ItemCount == 0 || value is null)
|
|
{
|
|
return AvaloniaProperty.UnsetValue;
|
|
}
|
|
|
|
var items = Items;
|
|
var binding = SelectedValueBinding;
|
|
|
|
if (binding is null)
|
|
{
|
|
// No SelectedValueBinding set, SelectedValue is the item itself
|
|
// Still verify the value passed in is in the Items list
|
|
var index = items!.IndexOf(value);
|
|
|
|
if (index >= 0)
|
|
{
|
|
return value;
|
|
}
|
|
else
|
|
{
|
|
return AvaloniaProperty.UnsetValue;
|
|
}
|
|
}
|
|
|
|
_bindingHelper ??= new BindingHelper(binding);
|
|
|
|
// Matching UWP behavior, if duplicates are present, return the first item matching
|
|
// the SelectedValue provided
|
|
foreach (var item in items!)
|
|
{
|
|
var itemValue = _bindingHelper.Evaluate(item);
|
|
|
|
if (itemValue.Equals(value))
|
|
{
|
|
return item;
|
|
}
|
|
}
|
|
|
|
return AvaloniaProperty.UnsetValue;
|
|
}
|
|
|
|
private void UpdateSelectedValueFromItem()
|
|
{
|
|
if (_isSelectionChangeActive)
|
|
return;
|
|
|
|
var binding = SelectedValueBinding;
|
|
var item = SelectedItem;
|
|
|
|
if (binding is null || item is null)
|
|
{
|
|
// No SelectedValueBinding, SelectedValue is Item itself
|
|
try
|
|
{
|
|
_isSelectionChangeActive = true;
|
|
SelectedValue = item;
|
|
}
|
|
finally
|
|
{
|
|
_isSelectionChangeActive = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
_bindingHelper ??= new BindingHelper(binding);
|
|
|
|
try
|
|
{
|
|
_isSelectionChangeActive = true;
|
|
SelectedValue = _bindingHelper.Evaluate(item);
|
|
}
|
|
finally
|
|
{
|
|
_isSelectionChangeActive = false;
|
|
}
|
|
}
|
|
|
|
private void AutoScrollToSelectedItemIfNecessary()
|
|
{
|
|
if (AutoScrollToSelectedItem &&
|
|
!_hasScrolledToSelectedItem &&
|
|
Presenter is object &&
|
|
Selection.AnchorIndex >= 0 &&
|
|
IsAttachedToVisualTree)
|
|
{
|
|
ScrollIntoView(Selection.AnchorIndex);
|
|
_hasScrolledToSelectedItem = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when a container raises the <see cref="IsSelectedChangedEvent"/>.
|
|
/// </summary>
|
|
/// <param name="e">The event.</param>
|
|
private void ContainerSelectionChanged(RoutedEventArgs e)
|
|
{
|
|
if (!_ignoreContainerSelectionChanged &&
|
|
e.Source is Control control &&
|
|
e.Source is ISelectable selectable &&
|
|
control.Parent == this &&
|
|
IndexFromContainer(control) != -1)
|
|
{
|
|
UpdateSelection(control, selectable.IsSelected);
|
|
}
|
|
|
|
if (e.Source != this)
|
|
{
|
|
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>
|
|
/// <returns>The previous selection state.</returns>
|
|
private bool MarkContainerSelected(Control container, bool selected)
|
|
{
|
|
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;
|
|
}
|
|
finally
|
|
{
|
|
_ignoreContainerSelectionChanged = false;
|
|
}
|
|
}
|
|
|
|
private void UpdateContainerSelection()
|
|
{
|
|
if (Presenter?.Panel is Panel panel)
|
|
{
|
|
foreach (var container in panel.Children)
|
|
{
|
|
MarkContainerSelected(
|
|
container,
|
|
Selection.IsSelected(IndexFromContainer(container)));
|
|
}
|
|
}
|
|
}
|
|
|
|
private ISelectionModel CreateDefaultSelectionModel()
|
|
{
|
|
return new InternalSelectionModel
|
|
{
|
|
SingleSelect = !SelectionMode.HasAllFlags(SelectionMode.Multiple),
|
|
};
|
|
}
|
|
|
|
private void InitializeSelectionModel(ISelectionModel model)
|
|
{
|
|
if (_updateState is null)
|
|
{
|
|
model.Source = Items;
|
|
}
|
|
|
|
model.PropertyChanged += OnSelectionModelPropertyChanged;
|
|
model.SelectionChanged += OnSelectionModelSelectionChanged;
|
|
model.LostSelection += OnSelectionModelLostSelection;
|
|
|
|
if (model.SingleSelect)
|
|
{
|
|
SelectionMode &= ~SelectionMode.Multiple;
|
|
}
|
|
else
|
|
{
|
|
SelectionMode |= SelectionMode.Multiple;
|
|
}
|
|
|
|
_oldSelectedIndex = model.SelectedIndex;
|
|
_oldSelectedItem = model.SelectedItem;
|
|
|
|
if (AlwaysSelected && model.Count == 0)
|
|
{
|
|
model.SelectedIndex = 0;
|
|
}
|
|
|
|
UpdateContainerSelection();
|
|
|
|
if (SelectedIndex != -1)
|
|
{
|
|
RaiseEvent(new SelectionChangedEventArgs(
|
|
SelectionChangedEvent,
|
|
Array.Empty<object>(),
|
|
Selection.SelectedItems.ToArray()));
|
|
}
|
|
}
|
|
|
|
private void DeinitializeSelectionModel(ISelectionModel? model)
|
|
{
|
|
if (model is object)
|
|
{
|
|
model.PropertyChanged -= OnSelectionModelPropertyChanged;
|
|
model.SelectionChanged -= OnSelectionModelSelectionChanged;
|
|
}
|
|
}
|
|
|
|
private void BeginUpdating()
|
|
{
|
|
_updateState ??= new UpdateState();
|
|
_updateState.UpdateCount++;
|
|
}
|
|
|
|
private void EndUpdating()
|
|
{
|
|
if (_updateState is object && --_updateState.UpdateCount == 0)
|
|
{
|
|
var state = _updateState;
|
|
_updateState = null;
|
|
|
|
if (state.Selection.HasValue)
|
|
{
|
|
Selection = state.Selection.Value;
|
|
}
|
|
|
|
if (state.SelectedItems.HasValue)
|
|
{
|
|
SelectedItems = state.SelectedItems.Value;
|
|
}
|
|
|
|
Selection.Source = Items;
|
|
|
|
if (Items is null)
|
|
{
|
|
Selection.Clear();
|
|
}
|
|
|
|
if (state.SelectedValue.HasValue)
|
|
{
|
|
var item = FindItemWithValue(state.SelectedValue.Value);
|
|
if (item != AvaloniaProperty.UnsetValue)
|
|
state.SelectedItem = item;
|
|
}
|
|
|
|
if (state.SelectedIndex.HasValue)
|
|
{
|
|
SelectedIndex = state.SelectedIndex.Value;
|
|
}
|
|
else if (state.SelectedItem.HasValue)
|
|
{
|
|
SelectedItem = state.SelectedItem.Value;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void StartTextSearchTimer()
|
|
{
|
|
_textSearchTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
|
_textSearchTimer.Tick += TextSearchTimer_Tick;
|
|
_textSearchTimer.Start();
|
|
}
|
|
|
|
private void StopTextSearchTimer()
|
|
{
|
|
if (_textSearchTimer == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_textSearchTimer.Tick -= TextSearchTimer_Tick;
|
|
_textSearchTimer.Stop();
|
|
|
|
_textSearchTimer = null;
|
|
}
|
|
|
|
private void TextSearchTimer_Tick(object? sender, EventArgs e)
|
|
{
|
|
_textSearchTerm = string.Empty;
|
|
StopTextSearchTimer();
|
|
}
|
|
|
|
// When in a BeginInit..EndInit block, or when the DataContext is updating, we need to
|
|
// defer changes to the selection model because we have no idea in which order properties
|
|
// will be set. Consider:
|
|
//
|
|
// - Both Items and SelectedItem are bound
|
|
// - The DataContext changes
|
|
// - The binding for SelectedItem updates first, producing an item
|
|
// - Items is searched to find the index of the new selected item
|
|
// - However Items isn't yet updated; the item is not found
|
|
// - SelectedIndex is incorrectly set to -1
|
|
//
|
|
// This logic cannot be encapsulated in SelectionModel because the selection model can also
|
|
// be bound, consider:
|
|
//
|
|
// - Both Items and Selection are bound
|
|
// - The DataContext changes
|
|
// - The binding for Items updates first
|
|
// - The new items are assigned to Selection.Source
|
|
// - The binding for Selection updates, producing a new SelectionModel
|
|
// - Both the old and new SelectionModels have the incorrect Source
|
|
private class UpdateState
|
|
{
|
|
private Optional<int> _selectedIndex;
|
|
private Optional<object?> _selectedItem;
|
|
private Optional<object?> _selectedValue;
|
|
|
|
public int UpdateCount { get; set; }
|
|
public Optional<ISelectionModel> Selection { get; set; }
|
|
public Optional<IList?> SelectedItems { get; set; }
|
|
|
|
public Optional<int> SelectedIndex
|
|
{
|
|
get => _selectedIndex;
|
|
set
|
|
{
|
|
_selectedIndex = value;
|
|
_selectedItem = default;
|
|
}
|
|
}
|
|
|
|
public Optional<object?> SelectedItem
|
|
{
|
|
get => _selectedItem;
|
|
set
|
|
{
|
|
_selectedItem = value;
|
|
_selectedIndex = default;
|
|
}
|
|
}
|
|
|
|
public Optional<object?> SelectedValue
|
|
{
|
|
get => _selectedValue;
|
|
set
|
|
{
|
|
_selectedValue = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper class for evaluating a binding from an Item and IBinding instance
|
|
/// </summary>
|
|
private class BindingHelper : StyledElement
|
|
{
|
|
public BindingHelper(IBinding binding)
|
|
{
|
|
UpdateBinding(binding);
|
|
}
|
|
|
|
public static readonly StyledProperty<object> ValueProperty =
|
|
AvaloniaProperty.Register<BindingHelper, object>("Value");
|
|
|
|
public object Evaluate(object? dataContext)
|
|
{
|
|
dataContext = dataContext ?? throw new ArgumentNullException(nameof(dataContext));
|
|
|
|
// Only update the DataContext if necessary
|
|
if (!dataContext.Equals(DataContext))
|
|
DataContext = dataContext;
|
|
|
|
return GetValue(ValueProperty);
|
|
}
|
|
|
|
public void UpdateBinding(IBinding binding)
|
|
{
|
|
_lastBinding = binding;
|
|
var ib = binding.Initiate(this, ValueProperty);
|
|
if (ib is null)
|
|
{
|
|
throw new InvalidOperationException("Unable to create binding");
|
|
}
|
|
|
|
BindingOperations.Apply(this, ValueProperty, ib, null);
|
|
}
|
|
|
|
private IBinding? _lastBinding;
|
|
}
|
|
}
|
|
}
|
|
|