Browse Source

Added favourite brushes

Krzysztof Krysiński 3 weeks ago
parent
commit
5048875930

+ 1 - 1
src/Drawie

@@ -1 +1 @@
-Subproject commit 8aa2b51c530d7e9e558206b48f114d742dd4d864
+Subproject commit 7f45e7a8c91b03ac5641ae9d61c242ecf0e221cf

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changeables/Brushes/IBrush.cs

@@ -5,6 +5,6 @@ namespace PixiEditor.ChangeableDocument.Changeables.Brushes;
 public interface IBrush
 public interface IBrush
 {
 {
     public string? FilePath { get; }
     public string? FilePath { get; }
-    public Guid Id { get; }
+    public Guid OutputNodeId { get; }
     IReadOnlyDocument Document { get; }
     IReadOnlyDocument Document { get; }
 }
 }

+ 25 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Brushes/BrushOutputNode.cs

@@ -46,6 +46,7 @@ public class BrushOutputNode : Node
     public InputProperty<bool> AllowSampleStacking { get; }
     public InputProperty<bool> AllowSampleStacking { get; }
     public InputProperty<bool> AlwaysClear { get; }
     public InputProperty<bool> AlwaysClear { get; }
     public InputProperty<bool> SnapToPixels { get; }
     public InputProperty<bool> SnapToPixels { get; }
+    public InputProperty<string> Tags { get; }
 
 
     public InputProperty<IReadOnlyNodeGraph> Previous { get; }
     public InputProperty<IReadOnlyNodeGraph> Previous { get; }
 
 
@@ -57,6 +58,7 @@ public class BrushOutputNode : Node
     private BrushEngine previewEngine = new BrushEngine();
     private BrushEngine previewEngine = new BrushEngine();
 
 
     protected override bool ExecuteOnlyOnCacheChange => true;
     protected override bool ExecuteOnlyOnCacheChange => true;
+    public Guid PersistentId { get; private set; } = Guid.NewGuid();
 
 
     public const string PreviewSvg =
     public const string PreviewSvg =
         "M0.25 99.4606C0.25 99.4606 60.5709 79.3294 101.717 99.4606C147.825 122.019 199.75 99.4606 199.75 99.4606";
         "M0.25 99.4606C0.25 99.4606 60.5709 79.3294 101.717 99.4606C147.825 122.019 199.75 99.4606 199.75 99.4606";
@@ -84,6 +86,7 @@ public class BrushOutputNode : Node
         AllowSampleStacking = CreateInput<bool>("AllowSampleStacking", "ALLOW_SAMPLE_STACKING", false);
         AllowSampleStacking = CreateInput<bool>("AllowSampleStacking", "ALLOW_SAMPLE_STACKING", false);
         AlwaysClear = CreateInput<bool>("AlwaysClear", "ALWAYS_CLEAR", false);
         AlwaysClear = CreateInput<bool>("AlwaysClear", "ALWAYS_CLEAR", false);
         SnapToPixels = CreateInput<bool>("SnapToPixels", "SNAP_TO_PIXELS", false);
         SnapToPixels = CreateInput<bool>("SnapToPixels", "SNAP_TO_PIXELS", false);
+        Tags = CreateInput<string>("Tags", "TAGS", "");
         Previous = CreateInput<IReadOnlyNodeGraph>("Previous", "PREVIOUS", null);
         Previous = CreateInput<IReadOnlyNodeGraph>("Previous", "PREVIOUS", null);
     }
     }
 
 
@@ -104,6 +107,28 @@ public class BrushOutputNode : Node
         RenderPreviews(context.GetPreviewTexturesForNode(Id), context);
         RenderPreviews(context.GetPreviewTexturesForNode(Id), context);
     }
     }
 
 
+    public override void SerializeAdditionalData(IReadOnlyDocument target, Dictionary<string, object> additionalData)
+    {
+        base.SerializeAdditionalData(target, additionalData);
+        additionalData["PersistentId"] = PersistentId;
+    }
+
+    internal override void DeserializeAdditionalData(IReadOnlyDocument target, IReadOnlyDictionary<string, object> data, List<IChangeInfo> infos)
+    {
+        base.DeserializeAdditionalData(target, data, infos);
+        if (data.TryGetValue("PersistentId", out var persistentIdObj))
+        {
+            if (persistentIdObj is Guid persistentId)
+            {
+                PersistentId = persistentId;
+            }
+            else if (persistentIdObj is string persistentIdStr && Guid.TryParse(persistentIdStr, out Guid parsedGuid))
+            {
+                PersistentId = parsedGuid;
+            }
+        }
+    }
+
     private void RenderPreviews(List<PreviewRenderRequest>? previews, RenderContext ctx)
     private void RenderPreviews(List<PreviewRenderRequest>? previews, RenderContext ctx)
     {
     {
         var previewToRender = previews;
         var previewToRender = previews;

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changes/NodeGraph/ConversionTable.cs

@@ -100,7 +100,7 @@ public static class ConversionTable
                 typeof(IBrush), [
                 typeof(IBrush), [
                     (typeof(DocumentReference),
                     (typeof(DocumentReference),
                         new TypeConverter<IBrush, DocumentReference>(b =>
                         new TypeConverter<IBrush, DocumentReference>(b =>
-                            new DocumentReference(b.FilePath, b.Id, b.Document)))
+                            new DocumentReference(b.FilePath, b.OutputNodeId, b.Document)))
                 ]
                 ]
             }
             }
         };
         };

+ 1 - 0
src/PixiEditor.Extensions.CommonApi/UserPreferences/PreferencesConstants.cs

@@ -55,4 +55,5 @@ public static class PreferencesConstants
 
 
     public const string DisablePreviews = "DisablePreviews";
     public const string DisablePreviews = "DisablePreviews";
     public const bool DisablePreviewsDefault = false;
     public const bool DisablePreviewsDefault = false;
+    public const string FavouriteBrushes = "FavouriteBrushes";
 }
 }

BIN
src/PixiEditor/Data/Brushes/Basic.pixi


BIN
src/PixiEditor/Data/Brushes/Claws.pixi


BIN
src/PixiEditor/Data/Brushes/PixelCircle.pixi


BIN
src/PixiEditor/Data/Brushes/PixelSquare.pixi


+ 6 - 1
src/PixiEditor/Data/Localization/Languages/en.json

@@ -1228,5 +1228,10 @@
   "TIME_BASED_STABILIZATION": "Time Based - Stabilization based on time. Amount of smoothing is dependent on how much time has passed between points.",
   "TIME_BASED_STABILIZATION": "Time Based - Stabilization based on time. Amount of smoothing is dependent on how much time has passed between points.",
   "ALL": "All",
   "ALL": "All",
   "NONE": "None",
   "NONE": "None",
-  "SELECTED_CATEGORIES": "{0} selected"
+  "SELECTED_CATEGORIES": "{0} selected",
+  "BASIC": "Basic",
+  "ENGRAVING": "Engraving",
+  "PIXEL_PERFECT": "Pixel Perfect",
+  "ESSENTIALS": "Essentials",
+  "UNTAGGED": "Untagged"
 }
 }

+ 26 - 9
src/PixiEditor/Models/BrushEngine/Brush.cs

@@ -21,7 +21,9 @@ internal class Brush : IBrush
     IReadOnlyDocument IBrush.Document => Document.AccessInternalReadOnlyDocument();
     IReadOnlyDocument IBrush.Document => Document.AccessInternalReadOnlyDocument();
     public string Name { get; set; }
     public string Name { get; set; }
     public string? FilePath { get; }
     public string? FilePath { get; }
-    public Guid Id { get; }
+    public Guid OutputNodeId { get; }
+    public Guid PersistentId { get; }
+    public string[] Tags { get; set; } = Array.Empty<string>();
 
 
     public Brush(Uri uri)
     public Brush(Uri uri)
     {
     {
@@ -47,16 +49,20 @@ internal class Brush : IBrush
         if (outputNode != null)
         if (outputNode != null)
         {
         {
             name = outputNode.BrushName.Value;
             name = outputNode.BrushName.Value;
-            Id = outputNode.Id;
+            OutputNodeId = outputNode.Id;
+            PersistentId = outputNode.PersistentId;
         }
         }
         else
         else
         {
         {
-            Id = Guid.NewGuid();
+            OutputNodeId = Guid.NewGuid();
+            PersistentId = Guid.NewGuid();
         }
         }
 
 
         Name = name;
         Name = name;
         Document = doc;
         Document = doc;
 
 
+        Tags = ExtractTags(outputNode)?.ToArray() ?? [];
+
         stream.Close();
         stream.Close();
         stream.Dispose();
         stream.Dispose();
     }
     }
@@ -66,15 +72,26 @@ internal class Brush : IBrush
         Name = name;
         Name = name;
         Document = brushDocument;
         Document = brushDocument;
         FilePath = brushDocument.FullFilePath;
         FilePath = brushDocument.FullFilePath;
-        Id = brushDocument.NodeGraphHandler.AllNodes.OfType<BrushOutputNodeViewModel>().FirstOrDefault()?.Id ?? Guid.NewGuid();
+        BrushOutputNode? outputNode =
+            brushDocument.AccessInternalReadOnlyDocument().NodeGraph.AllNodes.OfType<BrushOutputNode>()
+                .FirstOrDefault();
+        if (outputNode != null)
+        {
+            OutputNodeId = outputNode.Id;
+            PersistentId = outputNode.PersistentId;
+            Tags = ExtractTags(outputNode)?.ToArray() ?? [];
+        }
+        else
+        {
+            OutputNodeId = Guid.NewGuid();
+            PersistentId = Guid.NewGuid();
+        }
     }
     }
 
 
-    public Brush(string name, IDocument brushDocument, Guid id)
+    private static IEnumerable<string> ExtractTags(BrushOutputNode outputNode)
     {
     {
-        Name = name;
-        Document = brushDocument;
-        FilePath = brushDocument.FullFilePath;
-        Id = id;
+        return outputNode?.Tags.Value
+            ?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(t => t.Trim());
     }
     }
 
 
     public override string ToString()
     public override string ToString()

+ 3 - 3
src/PixiEditor/Models/Controllers/BrushLibrary.cs

@@ -51,7 +51,7 @@ internal class BrushLibrary
                     }
                     }
 
 
                     var brush = new Brush(name, doc);
                     var brush = new Brush(name, doc);
-                    brushes.Add(brush.Id, brush);
+                    brushes.Add(brush.OutputNodeId, brush);
                 }
                 }
                 catch (Exception ex)
                 catch (Exception ex)
                 {
                 {
@@ -73,7 +73,7 @@ internal class BrushLibrary
             {
             {
                 var doc = Importer.ImportDocument(file, false);
                 var doc = Importer.ImportDocument(file, false);
                 var brush = new Brush(Path.GetFileNameWithoutExtension(file), doc);
                 var brush = new Brush(Path.GetFileNameWithoutExtension(file), doc);
-                brushes.Add(brush.Id, brush);
+                brushes.Add(brush.OutputNodeId, brush);
             }
             }
             catch (Exception ex)
             catch (Exception ex)
             {
             {
@@ -93,7 +93,7 @@ internal class BrushLibrary
     public void Add(Brush brush)
     public void Add(Brush brush)
     {
     {
         var oldBrushes = Brushes.Values.ToList();
         var oldBrushes = Brushes.Values.ToList();
-        if (brushes.TryAdd(brush.Id, brush))
+        if (brushes.TryAdd(brush.OutputNodeId, brush))
         {
         {
             BrushesChanged?.Invoke();
             BrushesChanged?.Invoke();
         }
         }

+ 8 - 2
src/PixiEditor/Models/DocumentModels/DocumentUpdater.cs

@@ -35,6 +35,7 @@ using PixiEditor.ViewModels;
 using PixiEditor.ViewModels.Document;
 using PixiEditor.ViewModels.Document;
 using PixiEditor.ViewModels.Document.Blackboard;
 using PixiEditor.ViewModels.Document.Blackboard;
 using PixiEditor.ViewModels.Document.Nodes;
 using PixiEditor.ViewModels.Document.Nodes;
+using PixiEditor.ViewModels.Document.Nodes.Brushes;
 using PixiEditor.ViewModels.Nodes;
 using PixiEditor.ViewModels.Nodes;
 using PixiEditor.ViewModels.SubViewModels;
 using PixiEditor.ViewModels.SubViewModels;
 
 
@@ -1006,8 +1007,13 @@ internal class DocumentUpdater
 
 
             string name = info.Inputs.FirstOrDefault(x => x.PropertyName == BrushOutputNode.BrushNameProperty)
             string name = info.Inputs.FirstOrDefault(x => x.PropertyName == BrushOutputNode.BrushNameProperty)
                 ?.InputValue?.ToString() ?? "Unnamed";
                 ?.InputValue?.ToString() ?? "Unnamed";
-            toolsHandler.BrushLibrary.Add(
-                new Brush(name, doc, info.Id));
+
+            doc.NodeGraphHandler.NodeLookup.TryGetValue(info.Id, out var node);
+            if (node is BrushOutputNodeViewModel brushVm)
+            {
+                toolsHandler.BrushLibrary.Add(
+                    new Brush(name, doc));
+            }
         }
         }
     }
     }
 
 

+ 1 - 1
src/PixiEditor/Models/Serialization/Factories/BrushSerializationFactory.cs

@@ -30,7 +30,7 @@ internal class BrushSerializationFactory : SerializationFactory<byte[], Brush>
             int docLength = extractor.GetInt();
             int docLength = extractor.GetInt();
             byte[] docBytes = extractor.GetByteSpan(docLength).ToArray();
             byte[] docBytes = extractor.GetByteSpan(docLength).ToArray();
             var doc = PixiParser.V5.Deserialize(docBytes).ToDocument();
             var doc = PixiParser.V5.Deserialize(docBytes).ToDocument();
-            original = new Brush(name, doc, doc.NodeGraph.AllNodes.OfType<BrushOutputNodeViewModel>().FirstOrDefault()?.Id ?? Guid.NewGuid());
+            original = new Brush(name, doc);
 
 
             return true;
             return true;
         }
         }

+ 46 - 2
src/PixiEditor/ViewModels/BrushSystem/BrushViewModel.cs

@@ -1,4 +1,5 @@
-using ChunkyImageLib;
+using System.Collections.ObjectModel;
+using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
 using ChunkyImageLib.DataHolders;
 using Drawie.Backend.Core;
 using Drawie.Backend.Core;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces;
@@ -6,6 +7,7 @@ using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Numerics;
 using Drawie.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Brushes;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Brushes;
 using PixiEditor.ChangeableDocument.Rendering;
 using PixiEditor.ChangeableDocument.Rendering;
+using PixiEditor.Extensions.CommonApi.UserPreferences;
 using PixiEditor.Models.BrushEngine;
 using PixiEditor.Models.BrushEngine;
 
 
 namespace PixiEditor.ViewModels.BrushSystem;
 namespace PixiEditor.ViewModels.BrushSystem;
@@ -15,6 +17,7 @@ internal class BrushViewModel : ViewModelBase
     private Texture pointPreviewTexture;
     private Texture pointPreviewTexture;
     private Texture strokeTexture;
     private Texture strokeTexture;
     private Brush brush;
     private Brush brush;
+    private bool isFavourite;
 
 
     public Texture PointPreviewTexture
     public Texture PointPreviewTexture
     {
     {
@@ -49,6 +52,17 @@ internal class BrushViewModel : ViewModelBase
         get => Brush?.Name ?? "Unnamed Brush";
         get => Brush?.Name ?? "Unnamed Brush";
     }
     }
 
 
+    public ObservableCollection<string> Tags
+    {
+        get
+        {
+            if(Brush?.Tags == null)
+                return new ObservableCollection<string>();
+
+            return new ObservableCollection<string>(Brush.Tags);
+        }
+    }
+
     public Brush Brush
     public Brush Brush
     {
     {
         get { return brush; }
         get { return brush; }
@@ -61,18 +75,48 @@ internal class BrushViewModel : ViewModelBase
         }
         }
     }
     }
 
 
+    public bool IsFavourite
+    {
+        get => isFavourite;
+        set
+        {
+            if (SetProperty(ref isFavourite, value))
+            {
+                IPreferences.Current.UpdatePreference(PreferencesConstants.FavouriteBrushes, TogglePreference());
+            }
+        }
+    }
+
+    private List<Guid> TogglePreference()
+    {
+        var current = IPreferences.Current.GetPreference<List<Guid>>(PreferencesConstants.FavouriteBrushes) ?? new List<Guid>();
+        if (isFavourite)
+        {
+            if (!current.Contains(Brush.PersistentId))
+                current.Add(Brush.PersistentId);
+        }
+        else
+        {
+            if (current.Contains(Brush.PersistentId))
+                current.Remove(Brush.PersistentId);
+        }
+
+        return current;
+    }
+
     private int lastTextureCache;
     private int lastTextureCache;
 
 
     public BrushViewModel(Brush brush)
     public BrushViewModel(Brush brush)
     {
     {
         Brush = brush;
         Brush = brush;
         lastTextureCache = 0;
         lastTextureCache = 0;
+        isFavourite = IPreferences.Current.GetPreference<List<Guid>>(PreferencesConstants.FavouriteBrushes)?.Contains(Brush.PersistentId) ?? false;
     }
     }
 
 
     private void GeneratePreviewTextures()
     private void GeneratePreviewTextures()
     {
     {
         BrushOutputNode? brushNode =
         BrushOutputNode? brushNode =
-            Brush?.Document?.AccessInternalReadOnlyDocument().NodeGraph.LookupNode(Brush.Id) as BrushOutputNode;
+            Brush?.Document?.AccessInternalReadOnlyDocument().NodeGraph.LookupNode(Brush.OutputNodeId) as BrushOutputNode;
         if (brushNode == null)
         if (brushNode == null)
             return;
             return;
 
 

+ 2 - 1
src/PixiEditor/ViewModels/SubViewModels/ToolsViewModel.cs

@@ -177,8 +177,9 @@ internal class ToolsViewModel : SubViewModel<ViewModelMain>, IToolsHandler
             {
             {
                 string name = node.Inputs.FirstOrDefault(x => x.PropertyName == BrushOutputNode.BrushNameProperty)
                 string name = node.Inputs.FirstOrDefault(x => x.PropertyName == BrushOutputNode.BrushNameProperty)
                     ?.Value?.ToString() ?? "Unnamed";
                     ?.Value?.ToString() ?? "Unnamed";
+
                 BrushLibrary.Add(
                 BrushLibrary.Add(
-                    new Brush(name, node.Document, node.Id));
+                    new Brush(name, node.Document));
             }
             }
         }
         }
     }
     }

+ 1 - 1
src/PixiEditor/ViewModels/Tools/ToolSettings/Toolbars/BrushToolbar.cs

@@ -50,7 +50,7 @@ internal class BrushToolbar : Toolbar, IBrushToolbar
         }
         }
 
 
         var pipe = Brush.Document.ShareGraph();
         var pipe = Brush.Document.ShareGraph();
-        var data = new BrushData(pipe.TryAccessData(), Brush.Id) { AntiAliasing = AntiAliasing, StrokeWidth = (float)ToolSize };
+        var data = new BrushData(pipe.TryAccessData(), Brush.OutputNodeId) { AntiAliasing = AntiAliasing, StrokeWidth = (float)ToolSize };
 
 
         pipe.Dispose();
         pipe.Dispose();
         return data;
         return data;

+ 27 - 6
src/PixiEditor/Views/Input/BrushItem.axaml

@@ -7,6 +7,7 @@
              xmlns:input="clr-namespace:PixiEditor.Views.Input"
              xmlns:input="clr-namespace:PixiEditor.Views.Input"
              xmlns:surfaces="clr-namespace:Drawie.Backend.Core.Surfaces;assembly=Drawie.Backend.Core"
              xmlns:surfaces="clr-namespace:Drawie.Backend.Core.Surfaces;assembly=Drawie.Backend.Core"
              xmlns:controls="clr-namespace:Drawie.Interop.Avalonia.Core.Controls;assembly=Drawie.Interop.Avalonia.Core"
              xmlns:controls="clr-namespace:Drawie.Interop.Avalonia.Core.Controls;assembly=Drawie.Interop.Avalonia.Core"
+             xmlns:decorators="clr-namespace:PixiEditor.Views.Decorators"
              Background="Transparent" IsHitTestVisible="True"
              Background="Transparent" IsHitTestVisible="True"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="PixiEditor.Views.Input.BrushItem">
              x:Class="PixiEditor.Views.Input.BrushItem">
@@ -19,12 +20,17 @@
         </Style>
         </Style>
     </UserControl.Styles>
     </UserControl.Styles>
     <Grid>
     <Grid>
+        <Grid.RowDefinitions>
+            <RowDefinition Height="Auto" />
+            <RowDefinition Height="Auto" />
+        </Grid.RowDefinitions>
         <DockPanel LastChildFill="True"
         <DockPanel LastChildFill="True"
                    DataContext="{Binding RelativeSource={RelativeSource AncestorType=UserControl, Mode=FindAncestor}}">
                    DataContext="{Binding RelativeSource={RelativeSource AncestorType=UserControl, Mode=FindAncestor}}">
             <controls:DrawieTextureControl Height="30" Width="30"
             <controls:DrawieTextureControl Height="30" Width="30"
                                            SamplingOptions="Bilinear"
                                            SamplingOptions="Bilinear"
                                            Margin="0, 0, 5, 0"
                                            Margin="0, 0, 5, 0"
-                                           Texture="{Binding $parent[input:BrushItem].Brush.PointPreviewTexture}" DockPanel.Dock="Left" />
+                                           Texture="{Binding $parent[input:BrushItem].Brush.PointPreviewTexture}"
+                                           DockPanel.Dock="Left" />
             <TextBlock VerticalAlignment="Center"
             <TextBlock VerticalAlignment="Center"
                        Width="70"
                        Width="70"
                        ToolTip.Tip="{Binding Brush.Name}"
                        ToolTip.Tip="{Binding Brush.Name}"
@@ -35,6 +41,7 @@
                 <Button
                 <Button
                     Name="FavouriteButton" Width="28" Height="28"
                     Name="FavouriteButton" Width="28" Height="28"
                     Classes="pixi-icon" Padding="2"
                     Classes="pixi-icon" Padding="2"
+                    Command="{Binding ToggleFavorite}"
                     localization:Translator.TooltipKey="ADD_TO_FAVORITES">
                     localization:Translator.TooltipKey="ADD_TO_FAVORITES">
                     <Button.Styles>
                     <Button.Styles>
                         <Style Selector="Button:pointerover">
                         <Style Selector="Button:pointerover">
@@ -43,11 +50,25 @@
                     </Button.Styles>
                     </Button.Styles>
                 </Button>
                 </Button>
             </StackPanel>
             </StackPanel>
-            <controls:DrawieTextureControl Height="50" Width="100"
-                                           SamplingOptions="Bilinear"
-                                           Name="StrokePreviewControl"
-                                           Margin="0, 0, 5, 0"
-                                           Texture="{Binding $parent[input:BrushItem].DrawingStrokeTexture}" DockPanel.Dock="Left" />
+            <controls:DrawieTextureControl
+                Height="50" Width="100"
+                SamplingOptions="Bilinear"
+                Name="StrokePreviewControl"
+                Margin="0, 0, 5, 0"
+                Texture="{Binding $parent[input:BrushItem].DrawingStrokeTexture}"
+                DockPanel.Dock="Left" />
         </DockPanel>
         </DockPanel>
+        <ItemsControl Grid.Row="1" ItemsSource="{Binding $parent[input:BrushItem].Brush.Tags}">
+            <ItemsControl.ItemsPanel>
+                <ItemsPanelTemplate>
+                    <WrapPanel Orientation="Horizontal" ItemSpacing="5" />
+                </ItemsPanelTemplate>
+            </ItemsControl.ItemsPanel>
+            <ItemsControl.ItemTemplate>
+                <DataTemplate>
+                    <decorators:Chip localization:Translator.Key="{Binding }" />
+                </DataTemplate>
+            </ItemsControl.ItemTemplate>
+        </ItemsControl>
     </Grid>
     </Grid>
 </UserControl>
 </UserControl>

+ 23 - 4
src/PixiEditor/Views/Input/BrushItem.axaml.cs

@@ -22,7 +22,8 @@ namespace PixiEditor.Views.Input;
 
 
 internal partial class BrushItem : UserControl
 internal partial class BrushItem : UserControl
 {
 {
-    public static readonly StyledProperty<BrushViewModel> BrushProperty = AvaloniaProperty.Register<BrushItem, BrushViewModel>("Brush");
+    public static readonly StyledProperty<BrushViewModel> BrushProperty =
+        AvaloniaProperty.Register<BrushItem, BrushViewModel>("Brush");
 
 
     public static readonly StyledProperty<Texture> DrawingStrokeTextureProperty =
     public static readonly StyledProperty<Texture> DrawingStrokeTextureProperty =
         AvaloniaProperty.Register<BrushItem, Texture>(
         AvaloniaProperty.Register<BrushItem, Texture>(
@@ -55,6 +56,7 @@ internal partial class BrushItem : UserControl
             x.StopStrokePreviewLoop();
             x.StopStrokePreviewLoop();
             var brush = e.NewValue as BrushViewModel;
             var brush = e.NewValue as BrushViewModel;
             x.DrawingStrokeTexture = brush?.DrawingStrokeTexture;
             x.DrawingStrokeTexture = brush?.DrawingStrokeTexture;
+            x.PseudoClasses.Set(":favourite", brush?.IsFavourite ?? false);
         });
         });
     }
     }
 
 
@@ -69,6 +71,15 @@ internal partial class BrushItem : UserControl
         isPreviewingStroke = true;
         isPreviewingStroke = true;
     }
     }
 
 
+    public void ToggleFavorite()
+    {
+        if (Brush == null)
+            return;
+
+        Brush.IsFavourite = !Brush.IsFavourite;
+        PseudoClasses.Set(":favourite", Brush.IsFavourite);
+    }
+
     protected override void OnPointerExited(PointerEventArgs e)
     protected override void OnPointerExited(PointerEventArgs e)
     {
     {
         StopStrokePreviewLoop();
         StopStrokePreviewLoop();
@@ -80,7 +91,8 @@ internal partial class BrushItem : UserControl
             return;
             return;
 
 
         BrushOutputNode? brushNode =
         BrushOutputNode? brushNode =
-            Brush?.Brush?.Document?.AccessInternalReadOnlyDocument().NodeGraph.LookupNode(Brush?.Brush?.Id ?? Guid.Empty) as BrushOutputNode;
+            Brush?.Brush?.Document?.AccessInternalReadOnlyDocument().NodeGraph
+                .LookupNode(Brush?.Brush?.OutputNodeId ?? Guid.Empty) as BrushOutputNode;
         if (brushNode == null)
         if (brushNode == null)
             return;
             return;
 
 
@@ -117,8 +129,15 @@ internal partial class BrushItem : UserControl
         {
         {
             if (!enumerator.MoveNext())
             if (!enumerator.MoveNext())
             {
             {
-                isPreviewingStroke = false;
-                DrawingStrokeTexture = Brush?.DrawingStrokeTexture;
+                DispatcherTimer.RunOnce(() =>
+                {
+                    if (isPreviewingStroke)
+                    {
+                        StopStrokePreviewLoop();
+                        StartStrokePreviewLoop();
+                        isPreviewingStroke = true;
+                    }
+                }, TimeSpan.FromSeconds(1));
                 return false;
                 return false;
             }
             }
 
 

+ 2 - 2
src/PixiEditor/Views/Input/BrushPicker.axaml

@@ -65,12 +65,12 @@
                                             Margin="5,0,0,0">
                                             Margin="5,0,0,0">
                                 <TextBlock Name="SelectionText"/>
                                 <TextBlock Name="SelectionText"/>
                                 <DropDownButton.Flyout>
                                 <DropDownButton.Flyout>
-                                    <Flyout>
+                                    <Flyout Placement="BottomEdgeAlignedLeft">
                                         <ListBox SelectionMode="Multiple,Toggle"
                                         <ListBox SelectionMode="Multiple,Toggle"
                                                  Name="SelectCategoriesListBox">
                                                  Name="SelectCategoriesListBox">
                                             <ListBox.ItemTemplate>
                                             <ListBox.ItemTemplate>
                                                 <DataTemplate>
                                                 <DataTemplate>
-                                                    <TextBlock Text="{Binding }" Padding="5" />
+                                                    <TextBlock localization:Translator.Key="{Binding }" Padding="5" />
                                                 </DataTemplate>
                                                 </DataTemplate>
                                             </ListBox.ItemTemplate>
                                             </ListBox.ItemTemplate>
                                         </ListBox>
                                         </ListBox>

+ 76 - 11
src/PixiEditor/Views/Input/BrushPicker.axaml.cs

@@ -10,6 +10,7 @@ using Avalonia.Media;
 using Avalonia.Metadata;
 using Avalonia.Metadata;
 using Avalonia.VisualTree;
 using Avalonia.VisualTree;
 using CommunityToolkit.Mvvm.Input;
 using CommunityToolkit.Mvvm.Input;
+using PixiEditor.Extensions.CommonApi.UserPreferences;
 using PixiEditor.Models.Palettes;
 using PixiEditor.Models.Palettes;
 using PixiEditor.UI.Common.Localization;
 using PixiEditor.UI.Common.Localization;
 using PixiEditor.ViewModels.BrushSystem;
 using PixiEditor.ViewModels.BrushSystem;
@@ -107,6 +108,7 @@ internal partial class BrushPicker : UserControl
                 x.SelectedBrush = x.Brushes[0];
                 x.SelectedBrush = x.Brushes[0];
             }
             }
 
 
+            x.UpdateTags();
             x.UpdateResults();
             x.UpdateResults();
         });
         });
 
 
@@ -129,7 +131,8 @@ internal partial class BrushPicker : UserControl
     public BrushPicker()
     public BrushPicker()
     {
     {
         InitializeComponent();
         InitializeComponent();
-        Categories = new ObservableCollection<string>() { "Basic", "Texture", "Special", "Custom" };
+        Categories = new ObservableCollection<string>();
+        SelectionText.Text = new LocalizedString("ALL");
     }
     }
 
 
     protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
     protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
@@ -140,15 +143,57 @@ internal partial class BrushPicker : UserControl
         }
         }
 
 
         PopupToggle.Flyout.Opened += Flyout_Opened;
         PopupToggle.Flyout.Opened += Flyout_Opened;
-        SelectCategoriesListBox.ItemsSource = Categories;
+        var options = new ObservableCollection<string>(Categories);
+        options.Insert(0, "ALL");
+        options.Insert(0, "NONE");
+        SelectCategoriesListBox.ItemsSource = options;
         SelectCategoriesListBox.SelectionChanged += SelectCategoriesListBoxOnSelectionChanged;
         SelectCategoriesListBox.SelectionChanged += SelectCategoriesListBoxOnSelectionChanged;
 
 
         SelectCategoriesListBox.SelectAll();
         SelectCategoriesListBox.SelectAll();
 
 
+        IPreferences.Current.AddCallback(PreferencesConstants.FavouriteBrushes, OnFaviouritesChanged);
+    }
+
+    private void OnFaviouritesChanged(string s, object o)
+    {
+        UpdateResults();
+    }
+
+    private void UpdateTags()
+    {
+        Categories.Clear();
+        foreach (var brush in Brushes)
+        {
+            foreach (var tag in brush.Brush.Tags)
+            {
+                if (!string.IsNullOrWhiteSpace(tag) && !Categories.Contains(tag))
+                {
+                    Categories.Add(tag);
+                }
+            }
+        }
+
+        Categories.Add("UNTAGGED");
+
+        Categories = new ObservableCollection<string>(Categories.OrderBy(c => c));
     }
     }
 
 
     private void SelectCategoriesListBoxOnSelectionChanged(object? sender, SelectionChangedEventArgs e)
     private void SelectCategoriesListBoxOnSelectionChanged(object? sender, SelectionChangedEventArgs e)
     {
     {
+        if (e.AddedItems != null && e.AddedItems.Contains("ALL"))
+        {
+            SelectCategoriesListBox.SelectedItems = Categories.ToList();
+        }
+        else if (e.AddedItems != null && e.AddedItems.Contains("NONE"))
+        {
+            SelectCategoriesListBox.UnselectAll();
+        }
+
+        if (SelectCategoriesListBox.SelectedItems.Contains("NONE"))
+        {
+            SelectCategoriesListBox.SelectedItems.Remove("NONE");
+        }
+
         if (Categories.Count == SelectCategoriesListBox.SelectedItems.Count)
         if (Categories.Count == SelectCategoriesListBox.SelectedItems.Count)
         {
         {
             SelectionText.Text = new LocalizedString("ALL");
             SelectionText.Text = new LocalizedString("ALL");
@@ -159,11 +204,12 @@ internal partial class BrushPicker : UserControl
         }
         }
         else if (SelectCategoriesListBox.SelectedItems.Count == 1)
         else if (SelectCategoriesListBox.SelectedItems.Count == 1)
         {
         {
-            SelectionText.Text = SelectCategoriesListBox.SelectedItems[0].ToString();
+            SelectionText.Text = new LocalizedString(SelectCategoriesListBox.SelectedItems[0].ToString());
         }
         }
         else
         else
         {
         {
-            SelectionText.Text = new LocalizedString("SELECTED_CATEGORIES", SelectCategoriesListBox.SelectedItems.Count);
+            SelectionText.Text =
+                new LocalizedString("SELECTED_CATEGORIES", SelectCategoriesListBox.SelectedItems.Count);
         }
         }
 
 
         UpdateResults();
         UpdateResults();
@@ -173,6 +219,7 @@ internal partial class BrushPicker : UserControl
     {
     {
         PopupToggle.Flyout.Opened -= Flyout_Opened;
         PopupToggle.Flyout.Opened -= Flyout_Opened;
         SelectCategoriesListBox.SelectionChanged -= SelectCategoriesListBoxOnSelectionChanged;
         SelectCategoriesListBox.SelectionChanged -= SelectCategoriesListBoxOnSelectionChanged;
+        IPreferences.Current.RemoveCallback(PreferencesConstants.FavouriteBrushes, OnFaviouritesChanged);
     }
     }
 
 
     private void Flyout_Opened(object? sender, EventArgs e)
     private void Flyout_Opened(object? sender, EventArgs e)
@@ -201,18 +248,36 @@ internal partial class BrushPicker : UserControl
             }
             }
         }
         }
 
 
-        filtered = SelectedSortingIndex switch
+        var selectedTags = SelectCategoriesListBox.SelectedItems.Cast<string>().ToList();
+        if (selectedTags.Count == 0 && Categories.Count > 0)
         {
         {
-            (int)BrushSorting.Alphabetical => new ObservableCollection<BrushViewModel>(filtered.OrderBy(b => b.Name)),
-            _ => filtered
-        };
+            FilteredBrushes = new ObservableCollection<BrushViewModel>();
+            return;
+        }
 
 
+        if (selectedTags.Count == Categories.Count)
+        {
+            FilteredBrushes = filtered;
+        }
+        else
+        {
+            filtered = new ObservableCollection<BrushViewModel>(
+                filtered.Where(b =>
+                    selectedTags.Any(tag => b.Brush.Tags.Contains(tag) || tag == "UNTAGGED" && (!b.Brush.Tags.Any()))));
+        }
 
 
         bool descending = SortingDirection == "descending";
         bool descending = SortingDirection == "descending";
-        if (descending)
+
+        filtered = SelectedSortingIndex switch
         {
         {
-            filtered = new ObservableCollection<BrushViewModel>(filtered.Reverse());
-        }
+            (int)BrushSorting.Alphabetical => new ObservableCollection<BrushViewModel>(descending
+                ? filtered.OrderByDescending(b => b.Name)
+                : filtered.OrderBy(b => b.Name)),
+
+            _ => new ObservableCollection<BrushViewModel>(descending
+                ? filtered.Reverse().OrderByDescending(b => b.IsFavourite)
+                : filtered.OrderByDescending(b => b.IsFavourite)),
+        };
 
 
         FilteredBrushes = filtered;
         FilteredBrushes = filtered;
     }
     }