diff --git a/src/Artemis.Core/Models/Profile/Colors/ColorGradient.cs b/src/Artemis.Core/Models/Profile/Colors/ColorGradient.cs index c621eb5c7..589b7f5ba 100644 --- a/src/Artemis.Core/Models/Profile/Colors/ColorGradient.cs +++ b/src/Artemis.Core/Models/Profile/Colors/ColorGradient.cs @@ -11,7 +11,7 @@ namespace Artemis.Core /// /// A gradient containing a list of s /// - public class ColorGradient : IList, INotifyCollectionChanged + public class ColorGradient : IList, IList, INotifyCollectionChanged { private static readonly SKColor[] FastLedRainbow = { @@ -173,13 +173,25 @@ namespace Artemis.Core internal void Sort() { - _stops.Sort((a, b) => a.Position.CompareTo(b.Position)); + int requiredIndex = 0; + foreach (ColorGradientStop colorGradientStop in _stops.OrderBy(s => s.Position).ToList()) + { + int actualIndex = _stops.IndexOf(colorGradientStop); + if (requiredIndex != actualIndex) + { + _stops.RemoveAt(actualIndex); + _stops.Insert(requiredIndex, colorGradientStop); + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Move, colorGradientStop, requiredIndex, actualIndex)); + } + + requiredIndex++; + } } private void ItemOnPropertyChanged(object? sender, PropertyChangedEventArgs e) { Sort(); - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + OnStopChanged(); } #region Implementation of IEnumerable @@ -209,8 +221,16 @@ namespace Artemis.Core OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, _stops.IndexOf(item))); } - /// + public int Add(object? value) + { + if (value is ColorGradientStop stop) + _stops.Add(stop); + + return IndexOf(value); + } + + /// public void Clear() { foreach (ColorGradientStop item in _stops) @@ -219,6 +239,32 @@ namespace Artemis.Core OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } + /// + public bool Contains(object? value) + { + return _stops.Contains(value); + } + + /// + public int IndexOf(object? value) + { + return _stops.IndexOf(value); + } + + /// + public void Insert(int index, object? value) + { + if (value is ColorGradientStop stop) + _stops.Insert(index, stop); + } + + /// + public void Remove(object? value) + { + if (value is ColorGradientStop stop) + _stops.Remove(stop); + } + /// public bool Contains(ColorGradientStop item) { @@ -244,11 +290,29 @@ namespace Artemis.Core } /// + public void CopyTo(Array array, int index) + { + _stops.CopyTo((ColorGradientStop[]) array, index); + } + + /// public int Count => _stops.Count; /// + public bool IsSynchronized => false; + + /// + public object SyncRoot => this; + + /// public bool IsReadOnly => false; + object? IList.this[int index] + { + get => this[index]; + set => this[index] = (ColorGradientStop) value!; + } + #endregion #region Implementation of IList @@ -268,7 +332,7 @@ namespace Artemis.Core OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, _stops.IndexOf(item))); } - /// + /// public void RemoveAt(int index) { _stops[index].PropertyChanged -= ItemOnPropertyChanged; @@ -276,6 +340,8 @@ namespace Artemis.Core OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, index)); } + public bool IsFixedSize { get; } + /// public ColorGradientStop this[int index] { @@ -304,5 +370,15 @@ namespace Artemis.Core } #endregion + + /// + /// Occurs when any of the stops has changed in some way + /// + public event EventHandler? StopChanged; + + private void OnStopChanged() + { + StopChanged?.Invoke(this, EventArgs.Empty); + } } } \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI.Shared/Controls/GradientPicker/GradientPicker.cs b/src/Avalonia/Artemis.UI.Shared/Controls/GradientPicker/GradientPicker.cs new file mode 100644 index 000000000..8359568f5 --- /dev/null +++ b/src/Avalonia/Artemis.UI.Shared/Controls/GradientPicker/GradientPicker.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Specialized; +using System.Linq; +using Artemis.Core; +using Avalonia; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Media; + +namespace Artemis.UI.Shared.Controls.GradientPicker; + +public class GradientPicker : TemplatedControl +{ + private LinearGradientBrush _linearGradientBrush = new(); + + /// + /// Gets or sets the color gradient. + /// + public static readonly StyledProperty ColorGradientProperty = + AvaloniaProperty.Register(nameof(Core.ColorGradient), notifying: ColorGradientChanged, defaultValue: ColorGradient.GetUnicornBarf()); + + /// + /// Gets or sets the currently selected color stop. + /// + public static readonly StyledProperty SelectedColorStopProperty = + AvaloniaProperty.Register(nameof(SelectedColorStop), defaultBindingMode: BindingMode.TwoWay); + + /// + /// Gets the linear gradient brush representing the color gradient. + /// + public static readonly DirectProperty LinearGradientBrushProperty = + AvaloniaProperty.RegisterDirect(nameof(LinearGradientBrush), g => g.LinearGradientBrush); + + /// + /// Gets or sets the color gradient. + /// + public ColorGradient ColorGradient + { + get => GetValue(ColorGradientProperty); + set => SetValue(ColorGradientProperty, value); + } + + /// + /// Gets or sets the currently selected color stop. + /// + public ColorGradientStop? SelectedColorStop + { + get => GetValue(SelectedColorStopProperty); + set => SetValue(SelectedColorStopProperty, value); + } + + /// + /// Gets the linear gradient brush representing the color gradient. + /// + public LinearGradientBrush LinearGradientBrush + { + get => _linearGradientBrush; + private set => SetAndRaise(LinearGradientBrushProperty, ref _linearGradientBrush, value); + } + + private static void ColorGradientChanged(IAvaloniaObject sender, bool before) + { + if (before) + (sender as GradientPicker)?.Unsubscribe(); + else + (sender as GradientPicker)?.Subscribe(); + } + + private void Subscribe() + { + ColorGradient.CollectionChanged += ColorGradientOnCollectionChanged; + ColorGradient.StopChanged += ColorGradientOnStopChanged; + + UpdateGradient(); + SelectedColorStop = ColorGradient.FirstOrDefault(); + } + + private void Unsubscribe() + { + ColorGradient.CollectionChanged -= ColorGradientOnCollectionChanged; + ColorGradient.StopChanged -= ColorGradientOnStopChanged; + } + + private void ColorGradientOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + UpdateGradient(); + } + + private void ColorGradientOnStopChanged(object? sender, EventArgs e) + { + UpdateGradient(); + } + + private void UpdateGradient() + { + // Remove old stops + + // ReSharper disable once ConditionIsAlwaysTrueOrFalse + if (ColorGradient == null) + return; + + // Add new stops + + // Update the display gradient + GradientStops collection = new(); + foreach (ColorGradientStop c in ColorGradient.OrderBy(s => s.Position)) + collection.Add(new GradientStop(Color.FromArgb(c.Color.Alpha, c.Color.Red, c.Color.Green, c.Color.Blue), c.Position)); + LinearGradientBrush = new LinearGradientBrush {GradientStops = collection}; + } + + private void SelectColorStop(object? sender, PointerReleasedEventArgs e) + { + if (sender is IDataContextProvider dataContextProvider && dataContextProvider.DataContext is ColorGradientStop colorStop) + SelectedColorStop = colorStop; + } + + #region Overrides of Visual + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + Subscribe(); + base.OnAttachedToVisualTree(e); + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + Unsubscribe(); + base.OnDetachedFromVisualTree(e); + } + + #endregion +} \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI.Shared/Controls/GradientPicker/GradientPickerColorStop.cs b/src/Avalonia/Artemis.UI.Shared/Controls/GradientPicker/GradientPickerColorStop.cs new file mode 100644 index 000000000..d4872c39a --- /dev/null +++ b/src/Avalonia/Artemis.UI.Shared/Controls/GradientPicker/GradientPickerColorStop.cs @@ -0,0 +1,199 @@ +using System; +using Artemis.Core; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives; +using Avalonia.Input; + +namespace Artemis.UI.Shared.Controls.GradientPicker; + +public class GradientPickerColorStop : TemplatedControl +{ + private static ColorGradientStop? _draggingStop; + private static IPointer? _dragPointer; + + /// + /// Gets or sets the gradient picker. + /// + public static readonly StyledProperty GradientPickerProperty = + AvaloniaProperty.Register(nameof(GradientPicker), notifying: Notifying); + + private static void Notifying(IAvaloniaObject sender, bool before) + { + if (sender is not GradientPickerColorStop self) + return; + + if (before && self.GradientPicker != null) + self.GradientPicker.PropertyChanged -= self.GradientPickerOnPropertyChanged; + else if (self.GradientPicker != null) + self.GradientPicker.PropertyChanged += self.GradientPickerOnPropertyChanged; + + self.IsSelected = self.GradientPicker?.SelectedColorStop == self.ColorStop; + } + + /// + /// Gets or sets the color stop. + /// + public static readonly StyledProperty ColorStopProperty = + AvaloniaProperty.Register(nameof(ColorStop)); + + /// + /// Gets or sets the position reference to use when positioning and dragging this color stop. + /// If then dragging is not enabled. + /// + public static readonly StyledProperty PositionReferenceProperty = + AvaloniaProperty.Register(nameof(PositionReference)); + + /// + /// Gets the linear gradient brush representing the color gradient. + /// + public static readonly DirectProperty IsSelectedProperty = + AvaloniaProperty.RegisterDirect(nameof(IsSelected), g => g.IsSelected); + + private bool _isSelected; + private double _dragOffset; + + /// + /// Gets or sets the gradient picker. + /// + public GradientPicker? GradientPicker + { + get => GetValue(GradientPickerProperty); + set => SetValue(GradientPickerProperty, value); + } + + /// + /// Gets or sets the color stop. + /// + public ColorGradientStop ColorStop + { + get => GetValue(ColorStopProperty); + set => SetValue(ColorStopProperty, value); + } + + /// + /// Gets or sets the position reference to use when positioning and dragging this color stop. + /// If then dragging is not enabled. + /// + public IControl? PositionReference + { + get => GetValue(PositionReferenceProperty); + set => SetValue(PositionReferenceProperty, value); + } + + /// + /// Gets the linear gradient brush representing the color gradient. + /// + public bool IsSelected + { + get => _isSelected; + private set + { + SetAndRaise(IsSelectedProperty, ref _isSelected, value); + if (IsSelected) + PseudoClasses.Add(":selected"); + else + PseudoClasses.Remove(":selected"); + } + } + + private void GradientPickerOnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (GradientPicker != null && e.Property == GradientPicker.SelectedColorStopProperty) + { + IsSelected = GradientPicker.SelectedColorStop == ColorStop; + } + } + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || PositionReference == null) + return; + + _dragOffset = e.GetCurrentPoint(PositionReference).Position.X - GetPixelPosition(); + e.Pointer.Capture(this); + + // Store these in case the control is being recreated due to an array resort + // it's a bit ugly but it gives us a way to pick up dragging again with the new control + _dragPointer = e.Pointer; + _draggingStop = ColorStop; + e.Handled = true; + + if (GradientPicker != null) + GradientPicker.SelectedColorStop = ColorStop; + } + + private void OnPointerMoved(object? sender, PointerEventArgs e) + { + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || !ReferenceEquals(e.Pointer.Captured, this) || PositionReference == null) + { + if (_draggingStop != ColorStop) + return; + + _dragOffset = e.GetCurrentPoint(PositionReference).Position.X - GetPixelPosition(); + } + + double position = e.GetCurrentPoint(PositionReference).Position.X - _dragOffset; + ColorStop.Position = MathF.Round((float) Math.Clamp(position / PositionReference.Bounds.Width, 0, 1), 3, MidpointRounding.AwayFromZero); + e.Handled = true; + } + + private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (e.InitialPressMouseButton != MouseButton.Left) + return; + + e.Pointer.Capture(null); + e.Handled = true; + _draggingStop = null; + } + + private double GetPixelPosition() + { + if (PositionReference == null) + return 0; + + return PositionReference.Bounds.Width * ColorStop.Position; + } + + #region Overrides of Visual + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + if (GradientPicker != null) + GradientPicker.PropertyChanged += GradientPickerOnPropertyChanged; + + if (PositionReference != null) + { + PointerPressed += OnPointerPressed; + PointerMoved += OnPointerMoved; + PointerReleased += OnPointerReleased; + + // If this stop was previously being dragged, carry on dragging again + // This can happen if the control was recreated due to an array sort + if (_draggingStop == ColorStop && _dragPointer != null) + { + _dragPointer.Capture(this); + IsSelected = true; + } + } + + base.OnAttachedToVisualTree(e); + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + if (GradientPicker != null) + GradientPicker.PropertyChanged -= GradientPickerOnPropertyChanged; + PointerPressed -= OnPointerPressed; + PointerMoved -= OnPointerMoved; + PointerReleased -= OnPointerReleased; + + base.OnDetachedFromVisualTree(e); + } + + #endregion +} \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI.Shared/Converters/ColorGradientToGradientStopsConverter.cs b/src/Avalonia/Artemis.UI.Shared/Converters/ColorGradientToGradientStopsConverter.cs new file mode 100644 index 000000000..29f562d47 --- /dev/null +++ b/src/Avalonia/Artemis.UI.Shared/Converters/ColorGradientToGradientStopsConverter.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; +using System.Linq; +using Artemis.Core; +using Avalonia.Data.Converters; +using Avalonia.Media; +using SkiaSharp; + +namespace Artemis.UI.Shared.Converters; + +/// +/// Converts into a . +/// +public class ColorGradientToGradientStopsConverter : IValueConverter +{ + /// + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + ColorGradient? colorGradient = value as ColorGradient; + GradientStops collection = new(); + if (colorGradient == null) + return collection; + + foreach (ColorGradientStop c in colorGradient.OrderBy(s => s.Position)) + collection.Add(new GradientStop(Color.FromArgb(c.Color.Alpha, c.Color.Red, c.Color.Green, c.Color.Blue), c.Position)); + return collection; + } + + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + GradientStops? collection = value as GradientStops; + ColorGradient colorGradients = new(); + if (collection == null) + return colorGradients; + + foreach (GradientStop c in collection.OrderBy(s => s.Offset)) + colorGradients.Add(new ColorGradientStop(new SKColor(c.Color.R, c.Color.G, c.Color.B, c.Color.A), (float) c.Offset)); + return colorGradients; + } +} \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI.Shared/Converters/ParentWidthPercentageConverter.cs b/src/Avalonia/Artemis.UI.Shared/Converters/ParentWidthPercentageConverter.cs new file mode 100644 index 000000000..2d8219d0e --- /dev/null +++ b/src/Avalonia/Artemis.UI.Shared/Converters/ParentWidthPercentageConverter.cs @@ -0,0 +1,34 @@ +using System; +using System.Globalization; +using Avalonia.Controls; +using Avalonia.Data.Converters; + +namespace Artemis.UI.Shared.Converters; + +/// +/// Converts the width in percentage to a real number based on the width of the given parent +/// +public class ParentWidthPercentageConverter : IValueConverter +{ + #region Implementation of IValueConverter + + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (parameter is not IControl parent || value is not double percentage) + return value; + + return parent.Width / 100.0 * percentage; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (parameter is not IControl parent || value is not double real) + return value; + + return real / parent.Width * 100.0; + } + + #endregion +} \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI/Converters/SKColorToStringConverter.cs b/src/Avalonia/Artemis.UI.Shared/Converters/SKColorToStringConverter.cs similarity index 95% rename from src/Avalonia/Artemis.UI/Converters/SKColorToStringConverter.cs rename to src/Avalonia/Artemis.UI.Shared/Converters/SKColorToStringConverter.cs index e7707d8f1..7837c188e 100644 --- a/src/Avalonia/Artemis.UI/Converters/SKColorToStringConverter.cs +++ b/src/Avalonia/Artemis.UI.Shared/Converters/SKColorToStringConverter.cs @@ -3,7 +3,7 @@ using System.Globalization; using Avalonia.Data.Converters; using SkiaSharp; -namespace Artemis.UI.Converters; +namespace Artemis.UI.Shared.Converters; /// /// diff --git a/src/Avalonia/Artemis.UI.Shared/Converters/WidthNormalizedConverter.cs b/src/Avalonia/Artemis.UI.Shared/Converters/WidthNormalizedConverter.cs new file mode 100644 index 000000000..3e3021ecf --- /dev/null +++ b/src/Avalonia/Artemis.UI.Shared/Converters/WidthNormalizedConverter.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Data.Converters; + +namespace Artemis.UI.Shared.Converters; + +/// +/// Converts the width in percentage to a real number based on the width of the given parent +/// +public class WidthNormalizedConverter : IMultiValueConverter +{ + #region Implementation of IMultiValueConverter + + /// + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + object? first = values.FirstOrDefault(); + object? second = values.Skip(1).FirstOrDefault(); + if (first is float value && second is double totalWidth) + return (totalWidth / 1.0) * value; + + return 0.0; + } + + #endregion +} \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI.Shared/Services/GradientPickerService.cs b/src/Avalonia/Artemis.UI.Shared/Services/GradientPickerService.cs new file mode 100644 index 000000000..fd5836535 --- /dev/null +++ b/src/Avalonia/Artemis.UI.Shared/Services/GradientPickerService.cs @@ -0,0 +1,11 @@ +using Artemis.UI.Shared.Services.Interfaces; + +namespace Artemis.UI.Shared.Services; + +public class GradientPickerService : IGradientPickerService +{ +} + +public interface IGradientPickerService : IArtemisSharedUIService +{ +} \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI.Shared/Styles/Artemis.axaml b/src/Avalonia/Artemis.UI.Shared/Styles/Artemis.axaml index f77636350..1ee1b2a94 100644 --- a/src/Avalonia/Artemis.UI.Shared/Styles/Artemis.axaml +++ b/src/Avalonia/Artemis.UI.Shared/Styles/Artemis.axaml @@ -32,6 +32,8 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Avalonia/Artemis.UI/DefaultTypes/PropertyInput/SKColorPropertyInputView.axaml b/src/Avalonia/Artemis.UI/DefaultTypes/PropertyInput/SKColorPropertyInputView.axaml index 0f271bc78..a46f7fa29 100644 --- a/src/Avalonia/Artemis.UI/DefaultTypes/PropertyInput/SKColorPropertyInputView.axaml +++ b/src/Avalonia/Artemis.UI/DefaultTypes/PropertyInput/SKColorPropertyInputView.axaml @@ -5,6 +5,7 @@ xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:converters="clr-namespace:Artemis.UI.Converters" xmlns:propertyInput="clr-namespace:Artemis.UI.DefaultTypes.PropertyInput" + xmlns:shared="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared" mc:Ignorable="d" d:DesignWidth="200" d:DesignHeight="450" x:Class="Artemis.UI.DefaultTypes.PropertyInput.SKColorPropertyInputView" x:DataType="propertyInput:SKColorPropertyInputViewModel"> @@ -44,7 +45,7 @@ - + diff --git a/src/Avalonia/Artemis.UI/Screens/Workshop/WorkshopView.axaml b/src/Avalonia/Artemis.UI/Screens/Workshop/WorkshopView.axaml index e3a16ed34..cff2680bd 100644 --- a/src/Avalonia/Artemis.UI/Screens/Workshop/WorkshopView.axaml +++ b/src/Avalonia/Artemis.UI/Screens/Workshop/WorkshopView.axaml @@ -7,6 +7,7 @@ xmlns:controls1="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:attachedProperties="clr-namespace:Artemis.UI.Shared.AttachedProperties;assembly=Artemis.UI.Shared" xmlns:workshop="clr-namespace:Artemis.UI.Screens.Workshop" + xmlns:gradientPicker="clr-namespace:Artemis.UI.Shared.Controls.GradientPicker;assembly=Artemis.UI.Shared" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Workshop.WorkshopView" x:DataType="workshop:WorkshopViewModel"> @@ -45,6 +46,8 @@ + + diff --git a/src/Avalonia/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs b/src/Avalonia/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs index 95778a15f..97e951bf0 100644 --- a/src/Avalonia/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs +++ b/src/Avalonia/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs @@ -1,5 +1,6 @@ using System.Reactive; using System.Reactive.Linq; +using Artemis.Core; using Artemis.UI.Shared.Services.Builders; using Artemis.UI.Shared.Services.Interfaces; using Avalonia.Input; @@ -32,6 +33,8 @@ namespace Artemis.UI.Screens.Workshop public Cursor Cursor => _cursor.Value; + public ColorGradient ColorGradient { get; set; } = ColorGradient.GetUnicornBarf(); + private void ExecuteShowNotification(NotificationSeverity severity) { _notificationService.CreateNotification().WithTitle("Test title").WithMessage("Test message").WithSeverity(severity).Show();