Prechádzať zdrojové kódy

Merge branch 'master' into fixes/18.08

Krzysztof Krysiński 3 týždňov pred
rodič
commit
8edeeda5f3

+ 32 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/LineNode.cs

@@ -0,0 +1,32 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.ChangeableDocument.Rendering;
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.ColorsImpl.Paintables;
+using Drawie.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
+
+[NodeInfo("Line")]
+public class LineNode : ShapeNode<LineVectorData>
+{
+    public InputProperty<VecD> Start { get; }
+    public InputProperty<VecD> End { get; }
+    public InputProperty<Paintable> StrokeColor { get; }
+    public InputProperty<double> StrokeWidth { get; }
+
+    public LineNode()
+    {
+        Start = CreateInput<VecD>("LineStart", "LINE_START", VecD.Zero);
+        End = CreateInput<VecD>("LineEnd", "LINE_END", new VecD(32, 32));
+        StrokeColor = CreateInput<Paintable>("StrokeColor", "STROKE_COLOR", new Color(0, 0, 0, 255));
+        StrokeWidth = CreateInput<double>("StrokeWidth", "STROKE_WIDTH", 1);
+    }
+
+    protected override LineVectorData? GetShapeData(RenderContext context)
+    {
+        return new LineVectorData(Start.Value, End.Value)
+            { Stroke = StrokeColor.Value, StrokeWidth = (float)StrokeWidth.Value };
+    }
+
+    public override Node CreateCopy() => new LineNode();
+}

+ 37 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/RectangleNode.cs

@@ -0,0 +1,37 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.ChangeableDocument.Rendering;
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.ColorsImpl.Paintables;
+using Drawie.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
+
+[NodeInfo("Rectangle")]
+public class RectangleNode : ShapeNode<RectangleVectorData>
+{
+    public InputProperty<VecD> Center { get; }
+    public InputProperty<VecD> Size { get; }
+    public InputProperty<double> CornerRadius { get; }
+    public InputProperty<Paintable> StrokeColor { get; }
+    public InputProperty<Paintable> FillColor { get; }
+    public InputProperty<double> StrokeWidth { get; }
+
+    public RectangleNode()
+    {
+        Center = CreateInput<VecD>("Position", "POSITION", VecI.Zero);
+        Size = CreateInput<VecD>("Size", "SIZE", new VecD(32, 32)).WithRules(
+            v => v.Min(new VecD(0)));
+        CornerRadius = CreateInput<double>("CornerRadius", "RADIUS", 0);
+        StrokeColor = CreateInput<Paintable>("StrokeColor", "STROKE_COLOR", new Color(0, 0, 0, 255));
+        FillColor = CreateInput<Paintable>("FillColor", "FILL_COLOR", new Color(0, 0, 0, 255));
+        StrokeWidth = CreateInput<double>("StrokeWidth", "STROKE_WIDTH", 1);
+    }
+
+    protected override RectangleVectorData? GetShapeData(RenderContext context)
+    {
+        return new RectangleVectorData(Center.Value, Size.Value)
+            { CornerRadius = CornerRadius.Value, Stroke = StrokeColor.Value, FillPaintable = FillColor.Value, StrokeWidth = (float)StrokeWidth.Value };
+    }
+
+    public override Node CreateCopy() => new RectangleNode();
+}

+ 4 - 0
src/PixiEditor.UI.Common/Accents/Base.axaml

@@ -60,6 +60,8 @@
             <Color x:Key="IntSocketColor">#4C64B1</Color>
             <Color x:Key="StringSocketColor">#C9E4C6</Color>
             <Color x:Key="EllipseDataSocketColor">#a473a5</Color>
+            <Color x:Key="LineDataSocketColor">#816382</Color>
+            <Color x:Key="RectangleDataSocketColor">#825e8a</Color>
             <Color x:Key="PointsDataSocketColor">#e1d0e1</Color>
             <Color x:Key="TextDataSocketColor">#f2f2f2</Color>
             <Color x:Key="Matrix3X3SocketColor">#ffea4f</Color>
@@ -174,6 +176,8 @@
             <ConicGradientBrush x:Key="ShapeVectorDataSocketBrush"
                                 GradientStops="{StaticResource ShapeDataSocketGradient}" />
             <SolidColorBrush x:Key="EllipseVectorDataSocketBrush" Color="{StaticResource EllipseDataSocketColor}" />
+            <SolidColorBrush x:Key="LineVectorDataSocketBrush" Color="{StaticResource LineDataSocketColor}" />
+            <SolidColorBrush x:Key="RectangleVectorDataSocketBrush" Color="{StaticResource RectangleDataSocketColor}" />
             <SolidColorBrush x:Key="PointsVectorDataSocketBrush" Color="{StaticResource PointsDataSocketColor}" />
             <SolidColorBrush x:Key="TextVectorDataSocketBrush" Color="{StaticResource TextDataSocketColor}" />
 

+ 4 - 28
src/PixiEditor/Data/Localization/Languages/en.json

@@ -56,11 +56,8 @@
   "INCREASE_TOOL_SIZE": "Increase tool size",
   "DECREASE_TOOL_SIZE": "Decrease tool size",
   "UPDATE_READY": "Update is ready to be installed. Do you want to install it now?",
-  "NEW_UPDATE": "New update",
   "COULD_NOT_UPDATE_WITHOUT_ADMIN": "Couldn't update without admin privileges. Please run PixiEditor as administrator.",
   "INSUFFICIENT_PERMISSIONS": "Insufficient permissions",
-  "UPDATE_CHECK_FAILED": "Update check failed",
-  "COULD_NOT_CHECK_FOR_UPDATES": "Could not check if there is an update available.",
   "VERSION": "Version {0}",
   "BUILD_ID": "Build ID: {0}",
   "OPEN_TEMP_DIR": "Open temp directory",
@@ -98,11 +95,8 @@
   "SHORTCUT_TEMPLATES": "Shortcut templates",
   "RESET_ALL": "Reset all",
   "LAYER": "Layer",
-  "LAYER_DELETE_SELECTED": "Delete active layer/folder",
-  "LAYER_DELETE_SELECTED_DESCRIPTIVE": "Delete active layer or folder",
   "LAYER_DELETE_ALL_SELECTED": "Delete all selected layers/folders",
   "LAYER_DELETE_ALL_SELECTED_DESCRIPTIVE": "Delete all selected layers and/or folders",
-  "DELETE_SELECTED_PIXELS": "Delete selected pixels",
   "NEW_FOLDER": "New folder",
   "CREATE_NEW_FOLDER": "Create new folder",
   "NEW_LAYER": "New layer",
@@ -179,7 +173,6 @@
   "COPY_COLOR_SECONDARY_RGB_DESCRIPTIVE": "Copy secondary color as RGB code",
   "PALETTE_COLORS": "Palette Colors",
   "REPLACE_SECONDARY_BY_PRIMARY": "Replace secondary color by primary",
-  "REPLACE_SECONDARY_BY_PRIMARY_DESCRIPTIVE": "Replace the secondary color by the primary color",
   "REPLACE_PRIMARY_BY_SECONDARY": "Replace primary color by secondary",
   "REPLACE_PRIMARY_BY_SECONDARY_DESCRIPTIVE": "Replace the primary color by the secondary color",
   "OPEN_PALETTE_BROWSER": "Open palette browser",
@@ -451,7 +444,6 @@
   "DELETE_PALETTE_CONFIRMATION": "Are you sure you want to delete this palette? This cannot be undone.",
   "SHORTCUTS_IMPORTED": "Shortcuts from {0} were imported successfully.",
   "SHORTCUT_PROVIDER_DETECTED": "We've detected, that you have {0} installed. Do you want to import shortcuts from it?",
-  "IMPORT_FROM_INSTALLATION": "Import from installation",
   "IMPORT_INSTALLATION_OPTION1": "Import from installation",
   "IMPORT_INSTALLATION_OPTION2": "Use defaults",
   "IMPORT_FROM_TEMPLATE": "Import from template",
@@ -474,7 +466,6 @@
   "SHORTCUT_ALREADY_ASSIGNED_OVERWRITE": "This shortcut is already assigned to '{0}'\nDo you want to replace the existing shortcut?",
   "UNSAVED_CHANGES": "Unsaved changes",
   "DOCUMENT_MODIFIED_SAVE": "The document has been modified. Do you want to save changes?",
-  "SESSION_UNSAVED_DATA": "{0} with unsaved data. Are you sure?",
   "PROJECT_MAINTAINERS": "Project Maintainers",
   "OTHER_AWESOME_CONTRIBUTORS": "And other awesome contributors",
   "HELP": "Help",
@@ -489,7 +480,6 @@
   "TOGGLE_VERTICAL_SYMMETRY": "Toggle vertical symmetry",
   "TOGGLE_HORIZONTAL_SYMMETRY": "Toggle horizontal symmetry",
   "RESET_VIEWPORT": "Reset viewport",
-  "VIEWPORT_SETTINGS": "Viewport settings",
   "MOVE_TOOL_ACTION_DISPLAY_TRANSFORMING": "Click and hold mouse to move pixels in selected layers.",
   "CTRL_KEY": "Ctrl",
   "SHIFT_KEY": "Shift",
@@ -514,7 +504,6 @@
   "SECURITY_ERROR_MSG": "No rights to write to the specified location.",
   "IO_ERROR": "IO error",
   "IO_ERROR_MSG": "Error while writing to disk.",
-  "FAILED_ASSOCIATE_PIXI": "Failed to associate .pixi file with PixiEditor.",
   "COULD_NOT_SAVE_PALETTE": "There was an error while saving the palette.",
   "NO_COLORS_TO_SAVE": "There are no colors to save.",
   "CANVAS": "Canvas",
@@ -555,7 +544,6 @@
   "COLOR_PICKER_ACTION_DISPLAY_REFERENCE_ONLY": "Click to pick colors from the reference layer.",
   "COLOR_PICKER_ACTION_DISPLAY_CANVAS_ONLY": "Click to pick colors from the canvas.",
   "LOCALIZATION_DEBUG_WINDOW_TITLE": "Localization Debug Window",
-  "COMMAND_DEBUG_WINDOW_TITLE": "Command Debug Window",
   "SHORTCUTS_TITLE": "Shortcuts",
   "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_SHEAR_PERSPECTIVE": "Drag handles to scale transform. Hold Ctrl and drag a handle to scale from center. Hold Shift to scale proportionally. Hold Alt and drag a side handle to shear. Drag outside handles to rotate.",
   "TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_SHEAR_NOPERSPECTIVE": "Drag handles to scale transform. Hold Ctrl and drag a handle to scale from center. Hold Shift to scale proportionally. Hold Alt and drag a side handle to shear. Drag outside handles to rotate.",
@@ -606,7 +594,6 @@
   "BACKGROUND": "Background",
   "OPACITY": "Opacity",
   "IS_VISIBLE": "Is visible",
-  "CLIP_TO_MEMBER_BELOW": "Clip to member below",
   "BLEND_MODE": "Blend mode",
   "MASK": "Mask",
   "MASK_IS_VISIBLE": "Mask is visible",
@@ -643,10 +630,13 @@
   "BIAS": "Bias",
   "TILE_MODE": "Tile Mode",
   "ON_ALPHA": "On Alpha",
-  "PIXEL_COORDINATE": "Pixel Coordinate",
   "OUTPUT_NODE": "Output",
   "NOISE_NODE": "Noise",
   "ELLIPSE_NODE": "Ellipse",
+  "LINE_NODE": "Line",
+  "LINE_START": "Start",
+  "LINE_END": "End",
+  "RECTANGLE_NODE": "Rectangle",
   "CREATE_IMAGE_NODE": "Create Image",
   "FOLDER_NODE": "Folder",
   "IMAGE_LAYER_NODE": "Image Layer",
@@ -656,7 +646,6 @@
   "MERGE_NODE": "Merge",
   "MODIFY_IMAGE_LEFT_NODE": "Begin Modify Image",
   "MODIFY_IMAGE_RIGHT_NODE": "End Modify Image",
-  "MODIFY_IMAGE_PAIR_NODE": "Modify Image",
   "COMBINE_CHANNELS_NODE": "Combine Channels",
   "COMBINE_COLOR_NODE": "Combine Color",
   "COMBINE_VECD_NODE": "Combine Vector",
@@ -683,7 +672,6 @@
   "OUTLINE_EXAMPLE": "Automatic Outline",
   "BETA_ANIMATIONS": "Animations",
   "SLIME_EXAMPLE": "Animated Slime",
-  "SHOW_ALL_EXAMPLES": "Show all",
   "APPLY_FILTER_NODE": "Apply Filter",
   "FILTER": "Filter",
   "LERP_NODE": "Lerp",
@@ -701,7 +689,6 @@
   "POINTS": "Points",
   "MIN_DISTANCE": "Min. Distance",
   "MAX_POINTS": "Max. Points",
-  "PROBABILITY": "Probability",
   "DISTRIBUTE_POINTS": "Distribute points",
   "REMOVE_CLOSE_POINTS": "Remove close points",
   "RASTERIZE_SHAPE": "Rasterize Shape",
@@ -875,7 +862,6 @@
   "INPUT_MATRIX": "Input Matrix",
   "OUTPUT_MATRIX": "Output Matrix",
   "CENTER": "Center",
-  "CONTENT_OFFSET": "Content Offset",
   "CANVAS_POSITION": "Canvas Position",
   "CENTER_POSITION": "Center Position",
   "TILE_MODE_X": "Tile Mode X",
@@ -884,7 +870,6 @@
   "SKEW": "Skew",
   "OFFSET_NODE": "Offset",
   "SKEW_NODE": "Skew",
-  "ROTATION_NODE": "Rotation",
   "SCALE_NODE": "Scale",
   "ROTATE_NODE": "Rotate",
   "TRANSFORM_NODE": "Transform",
@@ -908,8 +893,6 @@
   "CONTRAST_VALUE": "Contrast",
   "TEMPERATURE_VALUE": "Temperature",
   "TINT_VALUE": "Tint",
-  "FAILED_DOWNLOADING_UPDATE_TITLE": "Failed to download update",
-  "FAILED_DOWNLOADING_UPDATE": "Failed to download the update. Try again later.",
   "UNEXPECTED_SHUTDOWN": "Unexpected shutdown",
   "UNEXPECTED_SHUTDOWN_MSG": "PixiEditor was unexpectedly shut down. We've loaded latest autosave of your files.",
   "OK": "OK",
@@ -956,10 +939,6 @@
   "IN_BOUNCE_EASING_TYPE": "In Bounce",
   "OUT_BOUNCE_EASING_TYPE": "Out Bounce",
   "IN_OUT_BOUNCE_EASING_TYPE": "In Out Bounce",
-  "CLAMP_SHADER_TILE_NODE": "Clamp",
-  "REPEAT_SHADER_TILE_NODE": "Repeat",
-  "MIRROR_SHADER_TILE_NODE": "Mirror",
-  "DECAL_SHADER_TILE_NODE": "Decal",
   "R_G_B_COMBINE_SEPARATE_COLOR_MODE": "RGB",
   "H_S_V_COMBINE_SEPARATE_COLOR_MODE": "HSV",
   "H_S_L_COMBINE_SEPARATE_COLOR_MODE": "HSL",
@@ -994,7 +973,6 @@
   "UP_TO_DATE_UNKNOWN": "Couldn't check for updates",
   "UP_TO_DATE": "PixiEditor is up to date",
   "UPDATE_AVAILABLE": "Update {0} is available",
-  "CHECKING_UPDATES": "Checking for updates...",
   "UPDATE_FAILED_DOWNLOAD": "Failed to download the update",
   "UPDATE_READY_TO_INSTALL": "Update is ready. Switch to {0}?",
   "SWITCH_TO_NEW_VERSION": "Switch",
@@ -1057,7 +1035,6 @@
   "SECONDARY_BG_COLOR": "Secondary background color",
   "RESET": "Reset",
   "INSTALL": "Install",
-  "MANAGE_ACCOUNT": "Manage",
   "OWNED_PRODUCTS": "Owned Content",
   "INSTALLING": "Installing",
   "INSTALLED": "Installed",
@@ -1074,7 +1051,6 @@
   "ERROR_GRAPH": "Graph setup produced an error. Fix it in the node graph",
   "COLOR_MATRIX_FILTER_NODE": "Color Matrix Filter",
   "WORKSPACE": "Workspace",
-  "EXPORT_ZONE_NODE": "Export Zone",
   "IS_DEFAULT_EXPORT": "Is Default Export",
   "EXPORT_OUTPUT": "Export Output",
   "RENDER_OUTPUT_SIZE": "Render Output Size",

+ 135 - 1
src/PixiEditor/Helpers/Extensions/EnumerableExtensions.cs

@@ -1,4 +1,5 @@
-using System.Collections.Generic;
+using System.Buffers;
+using System.Collections.Generic;
 
 namespace PixiEditor.Helpers.Extensions;
 
@@ -95,4 +96,137 @@ internal static class EnumerableExtensions
 
         return IndexOrNext(collection, predicate, index, false);
     }
+
+    public static T IndexOrNextInDirection<T>(this IEnumerable<T> collection, Predicate<T> predicate, int index, NextToDirection direction, bool overrun = true) => direction switch
+    {
+        NextToDirection.Forwards => IndexOrNext(collection, predicate, index, overrun),
+        NextToDirection.Backwards => IndexOrPrevious(collection, predicate, index, overrun),
+        _ => throw new ArgumentOutOfRangeException(nameof(direction)),
+    };
+    
+    /// <summary>
+    /// Returns the element that comes immediately after the specified <paramref name="index"/> 
+    /// in the given <paramref name="enumerable"/>, wrapping around to the first element if 
+    /// the end of the sequence is reached.
+    /// </summary>
+    /// <typeparam name="T">The type of the elements in the enumerable.</typeparam>
+    /// <param name="enumerable">The source enumerable.</param>
+    /// <param name="index">The index of the reference element. Must be non-negative.</param>
+    /// <returns>
+    /// The element immediately after the specified index, or the first element if the index 
+    /// refers to the last element. Returns <c>default</c> if the enumerable is empty.
+    /// </returns>
+    /// <remarks>
+    /// This method does not check whether the specified <paramref name="index"/> is within the 
+    /// bounds of the enumerable's size. Passing a positive out-of-range index may yield unexpected results.
+    /// </remarks>
+    /// <exception cref="ArgumentNullException">Thrown if <paramref name="enumerable"/> is <c>null</c>.</exception>
+    /// <exception cref="ArgumentOutOfRangeException">Thrown if <paramref name="index"/> is negative.</exception>
+    public static T? WrapNextAfterIndex<T>(this IEnumerable<T> enumerable, int index)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(index);
+        ArgumentNullException.ThrowIfNull(enumerable);
+
+        switch (enumerable)
+        {
+            case ICollection<T> collection:
+                return NextWithKnownCount(collection, index, collection.Count);
+            case IReadOnlyCollection<T> readOnlyCollection:
+                return NextWithKnownCount(readOnlyCollection, index, readOnlyCollection.Count);
+        }
+
+        using var enumerator = enumerable.GetEnumerator();
+
+        // If the enumerable is empty, return null
+        if (!enumerator.MoveNext())
+            return default;
+
+        var steps = index + 1;
+        var firstElement = enumerator.Current;
+
+        while (steps-- > 0)
+        {
+            if (!enumerator.MoveNext())
+                return firstElement;
+        }
+        
+        return enumerator.Current;
+    }
+
+    /// <summary>
+    /// Returns the element that comes immediately before the specified <paramref name="index"/> 
+    /// in the given <paramref name="enumerable"/>, wrapping around to the last element if 
+    /// the start of the sequence is reached.
+    /// </summary>
+    /// <typeparam name="T">The type of the elements in the enumerable.</typeparam>
+    /// <param name="enumerable">The source enumerable.</param>
+    /// <param name="index">The index of the reference element. Must be non-negative.</param>
+    /// <returns>
+    /// The element immediately before the specified index, or the last element if the index 
+    /// is <c>0</c>. Returns <c>default</c> if the enumerable is empty.
+    /// </returns>
+    /// <remarks>
+    /// This method does not check whether the specified <paramref name="index"/> is within the 
+    /// bounds of the enumerable's size. Passing a positive out-of-range index may yield unexpected results.
+    /// </remarks>
+    /// <exception cref="ArgumentNullException">Thrown if <paramref name="enumerable"/> is <c>null</c>.</exception>
+    /// <exception cref="ArgumentOutOfRangeException">Thrown if <paramref name="index"/> is negative.</exception>
+    public static T? WrapPreviousBeforeIndex<T>(this IEnumerable<T> enumerable, int index)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(index);
+        ArgumentNullException.ThrowIfNull(enumerable);
+        
+        return index == 0
+            ? enumerable.LastOrDefault()
+            : enumerable.ElementAtOrDefault(index - 1);
+    }
+
+    /// <summary>
+    /// Returns the element next to the specified <paramref name="index"/> in the given 
+    /// <paramref name="enumerable"/>, in the direction specified by <paramref name="direction"/>, 
+    /// wrapping around if necessary.
+    /// </summary>
+    /// <typeparam name="T">The type of the elements in the enumerable.</typeparam>
+    /// <param name="enumerable">The source enumerable.</param>
+    /// <param name="index">The index of the reference element. Must be non-negative.</param>
+    /// <param name="direction">
+    /// The direction in which to look for the next element (forwards or backwards).
+    /// </param>
+    /// <returns>
+    /// The element next to the specified index in the chosen direction, with wrap-around behavior.
+    /// Returns <c>default</c> if the enumerable is empty.
+    /// </returns>
+    /// <remarks>
+    /// This method does not check whether the specified <paramref name="index"/> is within the 
+    /// bounds of the enumerable's size. Passing a positive out-of-range index may yield unexpected results.
+    /// </remarks>
+    /// <exception cref="ArgumentNullException">Thrown if <paramref name="enumerable"/> is <c>null</c>.</exception>
+    /// <exception cref="ArgumentOutOfRangeException">Thrown if <paramref name="index"/> is negative.</exception>
+    /// <exception cref="ArgumentOutOfRangeException">Thrown if <paramref name="direction"/> is not a valid <see cref="NextToDirection"/> value.</exception>
+    public static T? WrapInDirectionOfIndex<T>(this IEnumerable<T> enumerable, int index, NextToDirection direction) =>
+        direction switch
+        {
+            NextToDirection.Forwards => WrapNextAfterIndex(enumerable, index),
+            NextToDirection.Backwards => WrapPreviousBeforeIndex(enumerable, index),
+            _ => throw new ArgumentOutOfRangeException(nameof(direction)),
+        };
+
+    private static T? NextWithKnownCount<T>(IEnumerable<T> collection, int index, int count)
+    {
+        if (count == 0)
+            return default;
+        
+        var newIndex = index + 1;
+
+        if (newIndex < 0 || newIndex >= count)
+            newIndex = newIndex < 0 ? count - 1 : 0;
+        
+        return collection.ElementAtOrDefault(newIndex);
+    }
+}
+
+enum NextToDirection
+{
+    Forwards = 1,
+    Backwards = -1
 }

+ 9 - 0
src/PixiEditor/Models/DocumentModels/DocumentTransformMode.cs

@@ -3,12 +3,21 @@
 namespace PixiEditor.Models.DocumentModels;
 internal enum DocumentTransformMode
 {
+    // Comments show localization in DocumentTransformViewModel.cs, comments are needed for localization key check pipeline
+    
+    // TRANSFORM_ACTION_DISPLAY_SCALE_NOROTATE_NOSHEAR_NOPERSPECTIVE
     [Description("SCALE_NOROTATE_NOSHEAR_NOPERSPECTIVE")]
     Scale_NoRotate_NoShear_NoPerspective,
+    
+    // TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_NOSHEAR_NOPERSPECTIVE
     [Description("SCALE_ROTATE_NOSHEAR_NOPERSPECTIVE")]
     Scale_Rotate_NoShear_NoPerspective,
+    
+    // TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_SHEAR_NOPERSPECTIVE
     [Description("SCALE_ROTATE_SHEAR_NOPERSPECTIVE")]
     Scale_Rotate_Shear_NoPerspective,
+    
+    // TRANSFORM_ACTION_DISPLAY_SCALE_ROTATE_SHEAR_PERSPECTIVE
     [Description("SCALE_ROTATE_SHEAR_PERSPECTIVE")]
     Scale_Rotate_Shear_Perspective
 }

+ 15 - 1
src/PixiEditor/Styles/Templates/NodePicker.axaml

@@ -4,7 +4,8 @@
                     xmlns:visuals="clr-namespace:PixiEditor.Views.Visuals"
                     xmlns:ui="clr-namespace:PixiEditor.UI.Common.Localization;assembly=PixiEditor.UI.Common"
                     xmlns:input="clr-namespace:PixiEditor.Views.Input"
-                    xmlns:nodes1="clr-namespace:PixiEditor.ViewModels.Nodes">
+                    xmlns:nodes1="clr-namespace:PixiEditor.ViewModels.Nodes"
+                    xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters">
     <ControlTheme TargetType="nodes:NodePicker" x:Key="{x:Type nodes:NodePicker}">
         <Setter Property="Template">
             <ControlTemplate>
@@ -55,6 +56,19 @@
                                                         CommandParameter="{Binding}"
                                                         HorizontalContentAlignment="Left"
                                                         IsVisible="{Binding !Hidden}">
+                                                        <Classes.KeyboardSelected>
+                                                            <MultiBinding Converter="{converters:AreEqualConverter}">
+                                                                <Binding />
+                                                                <Binding Path="SelectedNode" RelativeSource="{RelativeSource FindAncestor, AncestorType=nodes:NodePicker}" Mode="OneWay"/>
+                                                            </MultiBinding>
+                                                        </Classes.KeyboardSelected>
+                                                        
+                                                        <Button.Styles>
+                                                            <Style Selector="Button.KeyboardSelected">
+                                                                <Setter Property="Background" Value="{DynamicResource ThemeControlHighlightBrush}" />
+                                                            </Style>
+                                                        </Button.Styles>
+                                                        
                                                         <TextBlock Margin="10 0 0 0">
                                                             <Run Classes="pixi-icon"
                                                                  BaselineAlignment="Center"

+ 9 - 0
src/PixiEditor/ViewModels/Document/Nodes/Shapes/LineNodeViewModel.cs

@@ -0,0 +1,9 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
+using PixiEditor.ViewModels.Nodes;
+
+namespace PixiEditor.ViewModels.Document.Nodes.Shapes;
+
+[NodeViewModel("LINE_NODE", "SHAPE", PixiPerfectIcons.Line)]
+internal class LineNodeViewModel : NodeViewModel<LineNode>
+{
+}

+ 9 - 0
src/PixiEditor/ViewModels/Document/Nodes/Shapes/RectangleNodeViewModel.cs

@@ -0,0 +1,9 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
+using PixiEditor.ViewModels.Nodes;
+
+namespace PixiEditor.ViewModels.Document.Nodes.Shapes;
+
+[NodeViewModel("RECTANGLE_NODE", "SHAPE", PixiPerfectIcons.Square)]
+internal class RectangleNodeViewModel : NodeViewModel<RectangleNode>
+{
+}

+ 5 - 9
src/PixiEditor/Views/Main/CommandSearch/CommandSearchControl.axaml.cs

@@ -184,11 +184,11 @@ internal partial class CommandSearchControl : UserControl, INotifyPropertyChange
         }
         else if (e.Key is Key.Down or Key.PageDown)
         {
-            MoveSelection(1);
+            MoveSelection(NextToDirection.Forwards);
         }
         else if (e.Key is Key.Up or Key.PageUp)
         {
-            MoveSelection(-1);
+            MoveSelection(NextToDirection.Backwards);
         }
         else if (e.Key == Key.Escape ||
                  CommandController.Current.Commands["PixiEditor.Search.Toggle"].Shortcut
@@ -266,22 +266,18 @@ internal partial class CommandSearchControl : UserControl, INotifyPropertyChange
         }
     }
 
-    private void MoveSelection(int delta)
+    private void MoveSelection(NextToDirection direction)
     {
-        if (delta == 0)
-            return;
         if (SelectedResult is null)
         {
             SelectedResult = Results.FirstOrDefault(x => x.CanExecute);
             return;
         }
 
-        int newIndex = Results.IndexOf(SelectedResult) + delta;
+        var newIndex = Results.IndexOf(SelectedResult) + (int)direction;
         newIndex = (newIndex % Results.Count + Results.Count) % Results.Count;
 
-        SelectedResult = delta > 0
-            ? Results.IndexOrNext(x => x.CanExecute, newIndex)
-            : Results.IndexOrPrevious(x => x.CanExecute, newIndex);
+        SelectedResult = Results.IndexOrNextInDirection(x => x.CanExecute, newIndex, direction);
 
         newIndex = Results.IndexOf(SelectedResult);
         itemscontrol.ContainerFromIndex(newIndex)?.BringIntoView();

+ 82 - 8
src/PixiEditor/Views/Nodes/NodePicker.cs

@@ -5,7 +5,10 @@ using Avalonia.Controls;
 using Avalonia.Controls.Metadata;
 using Avalonia.Controls.Primitives;
 using Avalonia.Input;
+using Avalonia.Interactivity;
 using Avalonia.Threading;
+using Avalonia.VisualTree;
+using PixiEditor.Helpers.Extensions;
 using PixiEditor.Helpers.Nodes;
 using PixiEditor.ViewModels.Nodes;
 using PixiEditor.Views.Input;
@@ -43,6 +46,15 @@ public partial class NodePicker : TemplatedControl
         set => SetValue(SelectedCategoryProperty, value);
     }
 
+    public static readonly StyledProperty<NodeTypeInfo> SelectedNodeProperty =
+        AvaloniaProperty.Register<NodePicker, NodeTypeInfo>(nameof(SelectedNode));
+
+    public NodeTypeInfo? SelectedNode
+    {
+        get => GetValue(SelectedNodeProperty);
+        set => SetValue(SelectedNodeProperty, value);
+    }
+
     public ObservableCollection<NodeTypeInfo> AllNodeTypeInfos
     {
         get => GetValue(AllNodeTypeInfosProperty);
@@ -113,6 +125,7 @@ public partial class NodePicker : TemplatedControl
     {
         _inputBox = e.NameScope.Find<InputBox>("PART_InputBox");
 
+        _inputBox.Loaded += (_, _) => _inputBox.SelectAll();
         _inputBox.KeyDown += OnInputBoxKeyDown;
 
         _itemsControl = e.NameScope.Find<ItemsControl>("PART_NodeList");
@@ -158,6 +171,8 @@ public partial class NodePicker : TemplatedControl
             return;
         }
 
+        nodePicker.SelectedNode = null;
+        
         if (NodeAbbreviation.IsAbbreviation(nodePicker.SearchQuery, out var abbreviationName))
         {
             nodePicker.FilteredNodeGroups = nodePicker.NodeTypeGroupsFromQuery(abbreviationName);
@@ -235,8 +250,22 @@ public partial class NodePicker : TemplatedControl
 
     private void OnInputBoxKeyDown(object? sender, KeyEventArgs e)
     {
-        if (e.Key != Key.Enter)
+        switch (e.Key)
         {
+            case Key.Enter:
+                HandleEnterDown(sender, e);
+                return;
+            case Key.Down or Key.Up:
+                HandleKeyUpDown(sender, e); 
+                return;
+        }
+    }
+
+    private void HandleEnterDown(object? sender, KeyEventArgs e)
+    {
+        if (SelectedNode != null)
+        {
+            SelectNodeCommand.Execute(SelectedNode);
             return;
         }
 
@@ -247,17 +276,62 @@ public partial class NodePicker : TemplatedControl
             return;
         }
 
-        if (nodes == null && FilteredNodeGroups.Count > 0)
+        foreach (var node in nodes)
         {
-            SelectNodeCommand.Execute(FilteredNodeGroups[0]);
+            SelectNodeCommand.Execute(node);
         }
-        else
+    }
+
+    private void HandleKeyUpDown(object? sender, KeyEventArgs e)
+    {
+        if (SelectedNode == null)
         {
-            foreach (var node in nodes)
-            {
-                SelectNodeCommand.Execute(node);
-            }
+            SelectedNode = e.Key == Key.Down
+                ? FilteredNodeGroups.FirstOrDefault()?.NodeTypes.FirstOrDefault()
+                : FilteredNodeGroups.LastOrDefault()?.NodeTypes.LastOrDefault();
+                
+            return;
+        }
+
+        var direction = e.Key == Key.Down ? NextToDirection.Forwards : NextToDirection.Backwards;
+        SelectedNode = GetNodeNextTo(FilteredNodeGroups, SelectedNode, direction, out var group);
+
+        var container = _itemsControl.ContainerFromItem(group);
+        var buttonList = container.FindDescendantOfType<ItemsControl>();
+        
+        var button = buttonList.ContainerFromItem(SelectedNode);
+
+        const double padding = 2.6;
+        const double paddingHeight = padding * 2 + 1;
+        
+        // Bring Button above/below also into view
+        button.BringIntoView(new Rect(0, button.Bounds.Height * -padding, button.Bounds.Width, button.Bounds.Height * paddingHeight));
+    }
+
+    private static NodeTypeInfo? GetNodeNextTo(ObservableCollection<NodeTypeGroup> groups, NodeTypeInfo node, NextToDirection direction, out NodeTypeGroup group)
+    {
+        var currentGroup = groups.FirstOrDefault(x => x.NodeTypes.Contains(node));
+
+        group = currentGroup;
+        if (currentGroup == null)
+            return null;
+        
+        var indexInGroup = currentGroup.NodeTypes.IndexOf(node);
+        var groupIndex = groups.IndexOf(currentGroup);
+
+        if (direction == NextToDirection.Backwards && indexInGroup == 0)
+        {
+            group = groups.WrapPreviousBeforeIndex(groupIndex);
+            return group.NodeTypes.Last();
         }
+
+        if (direction == NextToDirection.Forwards && indexInGroup == currentGroup.NodeTypes.Count - 1)
+        {
+            group = groups.WrapNextAfterIndex(groupIndex);
+            return group.NodeTypes.First();
+        }
+
+        return currentGroup.NodeTypes[indexInGroup + (int)direction];
     }
 
     private static bool SearchComparer(NodeTypeInfo x, string lookFor) =>