diff --git a/src/Artemis.UI.Shared/Controls/Flyouts/MaterialIconPickerFlyout.cs b/src/Artemis.UI.Shared/Controls/Flyouts/MaterialIconPickerFlyout.cs new file mode 100644 index 000000000..99321b5e0 --- /dev/null +++ b/src/Artemis.UI.Shared/Controls/Flyouts/MaterialIconPickerFlyout.cs @@ -0,0 +1,38 @@ +using Avalonia.Controls; +using FluentAvalonia.UI.Controls; + +namespace Artemis.UI.Shared.Flyouts; + +/// +/// Defines a flyout that hosts a data model picker. +/// +public sealed class MaterialIconPickerFlyout : Flyout +{ + private MaterialIconPicker.MaterialIconPicker? _picker; + + /// + /// Gets the data model picker that the flyout hosts. + /// + public MaterialIconPicker.MaterialIconPicker MaterialIconPicker => _picker ??= new MaterialIconPicker.MaterialIconPicker(); + + /// + protected override Control CreatePresenter() + { + _picker ??= new MaterialIconPicker.MaterialIconPicker(); + _picker.Flyout = this; + FlyoutPresenter presenter = new() {Content = MaterialIconPicker}; + return presenter; + } + + #region Overrides of FlyoutBase + + /// + protected override void OnClosed() + { + if (_picker != null) + _picker.Flyout = null; + base.OnClosed(); + } + + #endregion +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPicker.cs b/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPicker.cs new file mode 100644 index 000000000..5777b21de --- /dev/null +++ b/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPicker.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive.Linq; +using System.Text.RegularExpressions; +using System.Windows.Input; +using Artemis.UI.Shared.Flyouts; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.LogicalTree; +using DynamicData; +using DynamicData.Binding; +using Material.Icons; +using ReactiveUI; + +namespace Artemis.UI.Shared.MaterialIconPicker; + +/// +/// Represents a Material icon picker picker that can be used to search and select a Material icon. +/// +public partial class MaterialIconPicker : TemplatedControl +{ + /// + /// Gets or sets the current Material icon. + /// + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay); + + /// + /// Gets the command to execute when deleting stops. + /// + public static readonly DirectProperty SelectIconProperty = + AvaloniaProperty.RegisterDirect(nameof(SelectIcon), g => g.SelectIcon); + + private readonly ICommand _selectIcon; + private ItemsRepeater? _iconsContainer; + + private SourceList? _iconsSource; + private TextBox? _searchBox; + private IDisposable? _sub; + private readonly Dictionary _enumNames; + + /// + public MaterialIconPicker() + { + _selectIcon = ReactiveCommand.Create(i => + { + Value = i; + Flyout?.Hide(); + }); + + // Build a list of enum names and values, this is required because a value may have more than one name + _enumNames = new Dictionary(); + MaterialIconKind[] values = Enum.GetValues(); + string[] names = Enum.GetNames(); + for (int index = 0; index < names.Length; index++) + _enumNames[names[index]] = values[index]; + } + + /// + /// Gets or sets the current Material icon. + /// + public MaterialIconKind? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + /// + /// Gets the command to execute when deleting stops. + /// + public ICommand SelectIcon + { + get => _selectIcon; + private init => SetAndRaise(SelectIconProperty, ref _selectIcon, value); + } + + internal MaterialIconPickerFlyout? Flyout { get; set; } + + private void Setup() + { + if (_searchBox == null || _iconsContainer == null) + return; + + // Build a list of values, they are not unique because a value with multiple names occurs once per name + _iconsSource = new SourceList(); + _iconsSource.AddRange(Enum.GetValues().Distinct()); + _sub = _iconsSource.Connect() + .Filter(_searchBox.WhenAnyValue(s => s.Text).Throttle(TimeSpan.FromMilliseconds(100)).Select(CreatePredicate)) + .Sort(SortExpressionComparer.Descending(p => p.ToString())) + .Bind(out ReadOnlyObservableCollection icons) + .Subscribe(); + _iconsContainer.ItemsSource = icons; + } + + #region Overrides of TemplatedControl + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + _searchBox = e.NameScope.Find("SearchBox"); + _iconsContainer = e.NameScope.Find("IconsContainer"); + + Setup(); + base.OnApplyTemplate(e); + } + + /// + protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e) + { + Setup(); + base.OnAttachedToLogicalTree(e); + } + + /// + protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e) + { + _iconsSource?.Dispose(); + _iconsSource = null; + _sub?.Dispose(); + _sub = null; + + if (_searchBox != null) + _searchBox.Text = ""; + base.OnDetachedFromLogicalTree(e); + } + + private Func CreatePredicate(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return _ => true; + + // Strip out whitespace and find all matching enum values + text = StripWhiteSpaceRegex().Replace(text, ""); + HashSet values = _enumNames.Where(n => n.Key.Contains(text, StringComparison.OrdinalIgnoreCase)).Select(n => n.Value).ToHashSet(); + // Only show those that matched + return data => values.Contains(data); + } + + [GeneratedRegex("\\s+")] + private static partial Regex StripWhiteSpaceRegex(); + + #endregion +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPickerButton.cs b/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPickerButton.cs new file mode 100644 index 000000000..53c751ac3 --- /dev/null +++ b/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPickerButton.cs @@ -0,0 +1,137 @@ +using System; +using Artemis.UI.Shared.Flyouts; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Interactivity; +using FluentAvalonia.Core; +using Material.Icons; + +namespace Artemis.UI.Shared.MaterialIconPicker; + +/// +/// Represents a button that can be used to pick a data model path in a flyout. +/// +public class MaterialIconPickerButton : TemplatedControl +{ + /// + /// Gets or sets the placeholder to show when nothing is selected. + /// + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder), "Click to select"); + + /// + /// Gets or sets the current Material icon. + /// + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay); + + /// + /// Gets or sets the desired flyout placement. + /// + public static readonly StyledProperty PlacementProperty = + AvaloniaProperty.Register(nameof(Placement), PlacementMode.BottomEdgeAlignedLeft); + + private Button? _button; + private MaterialIconPickerFlyout? _flyout; + private bool _flyoutActive; + + /// + /// Gets or sets the placeholder to show when nothing is selected. + /// + public string Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + /// + /// Gets or sets the current Material icon. + /// + public MaterialIconKind? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + /// + /// Gets or sets the desired flyout placement. + /// + public PlacementMode Placement + { + get => GetValue(PlacementProperty); + set => SetValue(PlacementProperty, value); + } + + /// + /// Raised when the flyout opens. + /// + public event TypedEventHandler? FlyoutOpened; + + /// + /// Raised when the flyout closes. + /// + public event TypedEventHandler? FlyoutClosed; + + private void OnButtonClick(object? sender, RoutedEventArgs e) + { + if (_flyout == null) + return; + + MaterialIconPickerFlyout flyout = new(); + flyout.FlyoutPresenterClasses.Add("material-icon-picker-presenter"); + flyout.MaterialIconPicker.Value = Value; + flyout.Placement = Placement; + + flyout.Closed += FlyoutOnClosed; + flyout.ShowAt(this); + FlyoutOpened?.Invoke(this, EventArgs.Empty); + + void FlyoutOnClosed(object? closedSender, EventArgs closedEventArgs) + { + Value = flyout.MaterialIconPicker.Value; + flyout.Closed -= FlyoutOnClosed; + FlyoutClosed?.Invoke(this, EventArgs.Empty); + } + } + + #region Overrides of TemplatedControl + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + if (_button != null) + _button.Click -= OnButtonClick; + base.OnApplyTemplate(e); + _button = e.NameScope.Find + + +