From 07f8209b7b7160024a52678dbd29d9ea9b0ac6cc Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 18 May 2023 13:27:04 +0200 Subject: [PATCH 1/2] UI - Initial icon picker commit --- .../Flyouts/MaterialIconPickerFlyout.cs | 25 ++++ .../MaterialIconPicker/MaterialIconPicker.cs | 110 +++++++++++++++ .../MaterialIconPickerButton.cs | 130 ++++++++++++++++++ src/Artemis.UI.Shared/Styles/Artemis.axaml | 2 + .../Styles/Controls/MaterialIconPicker.axaml | 55 ++++++++ .../Controls/MaterialIconPickerButton.axaml | 60 ++++++++ .../ProfileConfigurationEditView.axaml | 2 +- .../Screens/Workshop/WorkshopView.axaml | 3 + 8 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 src/Artemis.UI.Shared/Controls/Flyouts/MaterialIconPickerFlyout.cs create mode 100644 src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPicker.cs create mode 100644 src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPickerButton.cs create mode 100644 src/Artemis.UI.Shared/Styles/Controls/MaterialIconPicker.axaml create mode 100644 src/Artemis.UI.Shared/Styles/Controls/MaterialIconPickerButton.axaml 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..cf220336d --- /dev/null +++ b/src/Artemis.UI.Shared/Controls/Flyouts/MaterialIconPickerFlyout.cs @@ -0,0 +1,25 @@ +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(); + PickerFlyoutPresenter presenter = new() {Content = MaterialIconPicker}; + return presenter; + } +} \ 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..e1e65c9fa --- /dev/null +++ b/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPicker.cs @@ -0,0 +1,110 @@ +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 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); + + private SourceList? _iconsSource; + private TextBox? _searchBox; + private IDisposable? _sub; + private ItemsRepeater? _iconsContainer; + private readonly ICommand _selectIcon; + + /// + public MaterialIconPicker() + { + _selectIcon = ReactiveCommand.Create(i => Value = i); + } + + /// + /// 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 static readonly DirectProperty SelectIconProperty = + AvaloniaProperty.RegisterDirect(nameof(SelectIcon), g => g.SelectIcon); + + /// + /// Gets the command to execute when deleting stops. + /// + public ICommand SelectIcon + { + get => _selectIcon; + private init => SetAndRaise(SelectIconProperty, ref _selectIcon, value); + } + + #region Overrides of TemplatedControl + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + _searchBox = e.NameScope.Find("SearchBox"); + _iconsContainer = e.NameScope.Find("IconsContainer"); + if (_iconsContainer == null) + return; + + _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; + } + + /// + protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e) + { + _iconsSource?.Dispose(); + _iconsSource = null; + _sub?.Dispose(); + _sub = null; + base.OnDetachedFromLogicalTree(e); + } + + private Func CreatePredicate(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return _ => true; + + text = StripWhiteSpaceRegex().Replace(text, ""); + return data => data.ToString().Contains(text, StringComparison.InvariantCultureIgnoreCase); + } + + [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..c05b7d74d --- /dev/null +++ b/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPickerButton.cs @@ -0,0 +1,130 @@ +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)); + + 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; + + // Logic here is taken from Fluent Avalonia's ColorPicker which also reuses the same control since it's large + _flyout.MaterialIconPicker.Value = Value; + + _flyout.Placement = Placement; + _flyout.ShowAt(_button != null ? _button : this); + _flyoutActive = true; + + FlyoutOpened?.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 + + From ed3a77088103bceaed7484c14e5ea68e5ca4d448 Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 18 May 2023 19:39:15 +0200 Subject: [PATCH 2/2] UI - Added new icon picker to profile configuration dialog --- .../Flyouts/MaterialIconPickerFlyout.cs | 15 +++- .../MaterialIconPicker/MaterialIconPicker.cs | 69 ++++++++++++++----- .../MaterialIconPickerButton.cs | 23 ++++--- .../Controls/GradientPickerButton.axaml | 1 - .../Controls/MaterialIconPickerButton.axaml | 27 +++++--- .../ProfileConfigurationEditView.axaml | 28 ++------ .../ProfileConfigurationEditViewModel.cs | 10 +-- .../Sidebar/Dialogs/ProfileIconViewModel.cs | 13 +++- .../Screens/Workshop/WorkshopView.axaml | 3 +- 9 files changed, 119 insertions(+), 70 deletions(-) diff --git a/src/Artemis.UI.Shared/Controls/Flyouts/MaterialIconPickerFlyout.cs b/src/Artemis.UI.Shared/Controls/Flyouts/MaterialIconPickerFlyout.cs index cf220336d..99321b5e0 100644 --- a/src/Artemis.UI.Shared/Controls/Flyouts/MaterialIconPickerFlyout.cs +++ b/src/Artemis.UI.Shared/Controls/Flyouts/MaterialIconPickerFlyout.cs @@ -19,7 +19,20 @@ public sealed class MaterialIconPickerFlyout : Flyout protected override Control CreatePresenter() { _picker ??= new MaterialIconPicker.MaterialIconPicker(); - PickerFlyoutPresenter presenter = new() {Content = 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 index e1e65c9fa..5777b21de 100644 --- a/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPicker.cs +++ b/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPicker.cs @@ -5,6 +5,7 @@ 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; @@ -28,16 +29,35 @@ public partial class MaterialIconPicker : TemplatedControl 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 ItemsRepeater? _iconsContainer; - private readonly ICommand _selectIcon; + private readonly Dictionary _enumNames; /// public MaterialIconPicker() { - _selectIcon = ReactiveCommand.Create(i => Value = i); + _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]; } /// @@ -49,12 +69,6 @@ public partial class MaterialIconPicker : TemplatedControl set => SetValue(ValueProperty, value); } - /// - /// Gets the command to execute when deleting stops. - /// - public static readonly DirectProperty SelectIconProperty = - AvaloniaProperty.RegisterDirect(nameof(SelectIcon), g => g.SelectIcon); - /// /// Gets the command to execute when deleting stops. /// @@ -64,16 +78,14 @@ public partial class MaterialIconPicker : TemplatedControl private init => SetAndRaise(SelectIconProperty, ref _selectIcon, value); } - #region Overrides of TemplatedControl + internal MaterialIconPickerFlyout? Flyout { get; set; } - /// - protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + private void Setup() { - _searchBox = e.NameScope.Find("SearchBox"); - _iconsContainer = e.NameScope.Find("IconsContainer"); - if (_iconsContainer == null) + 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() @@ -84,6 +96,25 @@ public partial class MaterialIconPicker : TemplatedControl _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) { @@ -91,6 +122,9 @@ public partial class MaterialIconPicker : TemplatedControl _iconsSource = null; _sub?.Dispose(); _sub = null; + + if (_searchBox != null) + _searchBox.Text = ""; base.OnDetachedFromLogicalTree(e); } @@ -99,8 +133,11 @@ public partial class MaterialIconPicker : TemplatedControl if (string.IsNullOrWhiteSpace(text)) return _ => true; + // Strip out whitespace and find all matching enum values text = StripWhiteSpaceRegex().Replace(text, ""); - return data => data.ToString().Contains(text, StringComparison.InvariantCultureIgnoreCase); + 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+")] diff --git a/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPickerButton.cs b/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPickerButton.cs index c05b7d74d..53c751ac3 100644 --- a/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPickerButton.cs +++ b/src/Artemis.UI.Shared/Controls/MaterialIconPicker/MaterialIconPickerButton.cs @@ -31,7 +31,7 @@ public class MaterialIconPickerButton : TemplatedControl /// Gets or sets the desired flyout placement. /// public static readonly StyledProperty PlacementProperty = - AvaloniaProperty.Register(nameof(Placement)); + AvaloniaProperty.Register(nameof(Placement), PlacementMode.BottomEdgeAlignedLeft); private Button? _button; private MaterialIconPickerFlyout? _flyout; @@ -79,14 +79,21 @@ public class MaterialIconPickerButton : TemplatedControl if (_flyout == null) return; - // Logic here is taken from Fluent Avalonia's ColorPicker which also reuses the same control since it's large - _flyout.MaterialIconPicker.Value = Value; - - _flyout.Placement = Placement; - _flyout.ShowAt(_button != null ? _button : this); - _flyoutActive = true; - + 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 diff --git a/src/Artemis.UI.Shared/Styles/Controls/GradientPickerButton.axaml b/src/Artemis.UI.Shared/Styles/Controls/GradientPickerButton.axaml index 13f5ae0a5..75aafc057 100644 --- a/src/Artemis.UI.Shared/Styles/Controls/GradientPickerButton.axaml +++ b/src/Artemis.UI.Shared/Styles/Controls/GradientPickerButton.axaml @@ -28,7 +28,6 @@ -