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