Browse Source

Basic diff tree is working

Krzysztof Krysiński 1 year ago
parent
commit
33e49a31c9

+ 54 - 3
src/PixiEditor.Extensions.Tests/LayoutBuilderTests.cs

@@ -63,7 +63,7 @@ public class LayoutBuilderTests
     }
 
     [Fact]
-    public void TestStateChangesDataAndRebuildsControls()
+    public void TestStateChangesDataAndOnlyAppliesDiffProperties()
     {
         TestStatefulElement testStatefulElement = new TestStatefulElement();
         testStatefulElement.CreateState();
@@ -71,18 +71,69 @@ public class LayoutBuilderTests
 
         Assert.IsType<ContentPresenter>(native);
         Assert.IsType<Avalonia.Controls.Button>((native as ContentPresenter).Content);
+        Avalonia.Controls.Button button = (native as ContentPresenter).Content as Avalonia.Controls.Button;
+        Assert.IsType<TextBlock>(button.Content);
+
+        TextBlock textBlock = button.Content as TextBlock;
 
         Assert.Equal(0, testStatefulElement.State.ClickedTimes);
+        Assert.Equal(string.Format(TestState.Format, 0), textBlock.Text);
 
         ContentPresenter contentPresenter = native as ContentPresenter;
-        Avalonia.Controls.Button button = contentPresenter.Content as Avalonia.Controls.Button;
 
         button.RaiseEvent(new RoutedEventArgs(Avalonia.Controls.Button.ClickEvent));
 
         Assert.Equal(1, testStatefulElement.State.ClickedTimes);
 
+        Assert.IsType<ContentPresenter>(native);
+        Assert.Equal(contentPresenter, native);
+        Assert.IsType<Avalonia.Controls.Button>(contentPresenter.Content);
+        Assert.Equal(button, contentPresenter.Content);
+        Assert.IsType<TextBlock>(button.Content);
+        Assert.Equal(textBlock, button.Content);
+        Assert.Equal(string.Format(TestState.Format, 1), textBlock.Text);
+    }
+
+    [Fact]
+    public void TestStateRemovesChildFromTree()
+    {
+        TestStatefulElement testStatefulElement = new TestStatefulElement();
+        testStatefulElement.CreateState();
+        var native = testStatefulElement.BuildNative();
+
+        Assert.IsType<ContentPresenter>(native);
+        Assert.IsType<Avalonia.Controls.Button>((native as ContentPresenter).Content);
+        Avalonia.Controls.Button button = (native as ContentPresenter).Content as Avalonia.Controls.Button;
+
+        Assert.NotNull(button.Content);
+        Assert.IsType<TextBlock>(button.Content);
+
+        testStatefulElement.State.SetState(() => testStatefulElement.State.RemoveText = true);
+
+        Assert.Null(button.Content); // Old layout is updated and text is removed
+    }
+
+    [Fact]
+    public void TestStateAddsChildToTree()
+    {
+        TestStatefulElement testStatefulElement = new TestStatefulElement();
+        testStatefulElement.CreateState();
+        var native = testStatefulElement.BuildNative();
+
         Assert.IsType<ContentPresenter>(native);
         Assert.IsType<Avalonia.Controls.Button>((native as ContentPresenter).Content);
-        Assert.NotEqual(button, (native as ContentPresenter).Content);
+        Avalonia.Controls.Button button = (native as ContentPresenter).Content as Avalonia.Controls.Button;
+
+        Assert.NotNull(button.Content);
+        Assert.IsType<TextBlock>(button.Content);
+
+        testStatefulElement.State.SetState(() => testStatefulElement.State.RemoveText = true);
+
+        Assert.Null(button.Content); // Old layout is updated and text is removed
+
+        testStatefulElement.State.SetState(() => testStatefulElement.State.RemoveText = false);
+
+        Assert.NotNull(button.Content); // Old layout is updated and text is added
+        Assert.IsType<TextBlock>(button.Content);
     }
 }

+ 3 - 1
src/PixiEditor.Extensions.Tests/TestState.cs

@@ -8,13 +8,15 @@ namespace PixiEditor.Extensions.Test;
 
 public class TestState : State
 {
+    public const string Format = "Clicked: {0}";
     public int ClickedTimes { get; private set; } = 0;
+    public bool RemoveText { get; set; } = false;
 
     public override ILayoutElement<Control> Build()
     {
         return new Button(
             onClick: OnClick,
-            child: new Text($"Clicked: {ClickedTimes}"));
+            child: RemoveText ? null : new Text(string.Format(Format, ClickedTimes)));
     }
 
     private void OnClick(ElementEventArgs args)

+ 6 - 5
src/PixiEditor.Extensions/LayoutBuilding/Elements/Button.cs

@@ -1,4 +1,6 @@
-using Avalonia.Controls;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
 using Avalonia.Interactivity;
 using PixiEditor.Extensions.CommonApi.LayoutBuilding;
 using PixiEditor.Extensions.CommonApi.LayoutBuilding.Events;
@@ -24,10 +26,9 @@ public class Button : SingleChildLayoutElement
 
     public override Control BuildNative()
     {
-        Avalonia.Controls.Button btn = new Avalonia.Controls.Button()
-        {
-            Content = Child?.BuildNative(),
-        };
+        Avalonia.Controls.Button btn = new Avalonia.Controls.Button();
+        Binding binding = new Binding(nameof(Child)) { Source = this, Converter = LayoutElementToNativeControlConverter.Instance };
+        btn.Bind(Avalonia.Controls.Button.ContentProperty, binding);
 
         btn.Click += (sender, args) => RaiseEvent(nameof(Click), new ElementEventArgs());
 

+ 31 - 10
src/PixiEditor.Extensions/LayoutBuilding/Elements/Center.cs

@@ -1,30 +1,51 @@
-using Avalonia.Controls;
+using System.ComponentModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
 using PixiEditor.Extensions.CommonApi.LayoutBuilding;
 
 namespace PixiEditor.Extensions.LayoutBuilding.Elements;
 
-public class Center : SingleChildLayoutElement, IPropertyDeserializable
+public class Center : SingleChildLayoutElement
 {
+    private Panel panel;
     public Center(ILayoutElement<Control> child = null)
     {
         Child = child;
+        PropertyChanged += OnPropertyChanged;
     }
 
-    public override Control BuildNative()
+    private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
     {
-        return new Panel()
+        if(panel == null)
+        {
+            return;
+        }
+
+        if (e.PropertyName == nameof(Child))
         {
-            Children =
+            panel.Children.Clear();
+            if (Child != null)
             {
-                Child.BuildNative()
-            },
+                panel.Children.Add(Child.BuildNative());
+            }
+        }
+    }
+
+    public override Control BuildNative()
+    {
+        panel = new Panel()
+        {
             HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
             VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
         };
-    }
 
-    void IPropertyDeserializable.DeserializeProperties(List<object> values)
-    {
+        if (Child != null)
+        {
+            Control child = Child.BuildNative();
+            panel.Children.Add(child);
+        }
 
+        return panel;
     }
 }

+ 3 - 1
src/PixiEditor.Extensions/LayoutBuilding/Elements/IChildrenDeserializable.cs → src/PixiEditor.Extensions/LayoutBuilding/Elements/IChildHost.cs

@@ -3,7 +3,9 @@ using PixiEditor.Extensions.CommonApi.LayoutBuilding;
 
 namespace PixiEditor.Extensions.LayoutBuilding.Elements;
 
-public interface IChildrenDeserializable : IEnumerable<ILayoutElement<Control>>
+public interface IChildHost : IEnumerable<ILayoutElement<Control>>
 {
     public void DeserializeChildren(List<ILayoutElement<Control>> children);
+    public void AddChild(ILayoutElement<Control> child);
+    public void RemoveChild(ILayoutElement<Control> child);
 }

+ 2 - 1
src/PixiEditor.Extensions/LayoutBuilding/Elements/IPropertyDeserializable.cs

@@ -5,5 +5,6 @@ namespace PixiEditor.Extensions.LayoutBuilding.Elements;
 
 public interface IPropertyDeserializable
 {
-    public void DeserializeProperties(List<object> values);
+    public IEnumerable<object> GetProperties();
+    public void DeserializeProperties(IEnumerable<object> values);
 }

+ 1 - 6
src/PixiEditor.Extensions/LayoutBuilding/Elements/Layout.cs

@@ -3,7 +3,7 @@ using PixiEditor.Extensions.CommonApi.LayoutBuilding;
 
 namespace PixiEditor.Extensions.LayoutBuilding.Elements;
 
-public sealed class Layout : SingleChildLayoutElement, IPropertyDeserializable
+public sealed class Layout : SingleChildLayoutElement
 {
     public Layout(ILayoutElement<Control> body = null)
     {
@@ -20,9 +20,4 @@ public sealed class Layout : SingleChildLayoutElement, IPropertyDeserializable
 
         return panel;
     }
-
-    void IPropertyDeserializable.DeserializeProperties(List<object> values)
-    {
-
-    }
 }

+ 5 - 5
src/PixiEditor.Extensions/LayoutBuilding/Elements/LayoutBuilder.cs

@@ -94,7 +94,7 @@ public class LayoutBuilder
             deserializableProperties.DeserializeProperties(properties);
         }
 
-        if (element is IChildrenDeserializable customChildrenDeserializable)
+        if (element is IChildHost customChildrenDeserializable)
         {
             customChildrenDeserializable.DeserializeChildren(children);
         }
@@ -108,7 +108,7 @@ public class LayoutBuilder
 
             if (duplicatedIdTactic == DuplicateResolutionTactic.ReplaceRemoveChildren)
             {
-                if (managedElements[uniqueId] is IChildrenDeserializable childrenDeserializable)
+                if (managedElements[uniqueId] is IChildHost childrenDeserializable)
                 {
                     RemoveChildren(childrenDeserializable);
                 }
@@ -120,12 +120,12 @@ public class LayoutBuilder
         return layoutElement;
     }
 
-    private void RemoveChildren(IChildrenDeserializable childrenDeserializable)
+    private void RemoveChildren(IChildHost childHost)
     {
-        foreach (var child in childrenDeserializable)
+        foreach (var child in childHost)
         {
             managedElements.Remove(child.UniqueId);
-            if (child is IChildrenDeserializable childChildrenDeserializable)
+            if (child is IChildHost childChildrenDeserializable)
             {
                 RemoveChildren(childChildrenDeserializable);
             }

+ 19 - 2
src/PixiEditor.Extensions/LayoutBuilding/Elements/LayoutElement.cs

@@ -1,10 +1,12 @@
-using Avalonia.Controls;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using Avalonia.Controls;
 using PixiEditor.Extensions.CommonApi.LayoutBuilding;
 using PixiEditor.Extensions.CommonApi.LayoutBuilding.Events;
 
 namespace PixiEditor.Extensions.LayoutBuilding.Elements;
 
-public abstract class LayoutElement : ILayoutElement<Control>
+public abstract class LayoutElement : ILayoutElement<Control>, INotifyPropertyChanged
 {
     public int UniqueId { get; set; }
 
@@ -58,4 +60,19 @@ public abstract class LayoutElement : ILayoutElement<Control>
             eventHandler.Invoke(args);
         }
     }
+
+    public event PropertyChangedEventHandler? PropertyChanged;
+
+    protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+    {
+        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+    }
+
+    protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
+    {
+        if (EqualityComparer<T>.Default.Equals(field, value)) return false;
+        field = value;
+        OnPropertyChanged(propertyName);
+        return true;
+    }
 }

+ 20 - 3
src/PixiEditor.Extensions/LayoutBuilding/Elements/SingleChildLayoutElement.cs

@@ -4,16 +4,33 @@ using PixiEditor.Extensions.CommonApi.LayoutBuilding;
 
 namespace PixiEditor.Extensions.LayoutBuilding.Elements;
 
-public abstract class SingleChildLayoutElement : LayoutElement, ISingleChildLayoutElement<Control>, IChildrenDeserializable
+public abstract class SingleChildLayoutElement : LayoutElement, ISingleChildLayoutElement<Control>, IChildHost
 {
-    public ILayoutElement<Control>? Child { get; set; }
+    private ILayoutElement<Control>? _child;
+
+    public ILayoutElement<Control>? Child
+    {
+        get => _child;
+        set => SetField(ref _child, value);
+    }
+
     public abstract override Control BuildNative();
 
-    void IChildrenDeserializable.DeserializeChildren(List<ILayoutElement<Control>> children)
+    void IChildHost.DeserializeChildren(List<ILayoutElement<Control>> children)
     {
         Child = children.FirstOrDefault();
     }
 
+    public void AddChild(ILayoutElement<Control> child)
+    {
+        Child = child;
+    }
+
+    public void RemoveChild(ILayoutElement<Control> child)
+    {
+        Child = null;
+    }
+
     public IEnumerator<ILayoutElement<Control>> GetEnumerator()
     {
         if (Child != null)

+ 1 - 1
src/PixiEditor.Extensions/LayoutBuilding/Elements/State.cs

@@ -14,5 +14,5 @@ public abstract class State : IState<Control>
         StateChanged?.Invoke();
     }
 
-    public event Action? StateChanged;
+    public event Action StateChanged;
 }

+ 13 - 3
src/PixiEditor.Extensions/LayoutBuilding/Elements/StatefulContainer.cs

@@ -4,18 +4,28 @@ using PixiEditor.Extensions.CommonApi.LayoutBuilding;
 
 namespace PixiEditor.Extensions.LayoutBuilding.Elements;
 
-public class StatefulContainer : StatefulElement<ContainerState>, IChildrenDeserializable
+public class StatefulContainer : StatefulElement<ContainerState>, IChildHost
 {
     public override ContainerState CreateState()
     {
-        return new();
+         return new ContainerState();
     }
 
-    void IChildrenDeserializable.DeserializeChildren(List<ILayoutElement<Control>> children)
+    void IChildHost.DeserializeChildren(List<ILayoutElement<Control>> children)
     {
         State.Content = children.FirstOrDefault();
     }
 
+    public void AddChild(ILayoutElement<Control> child)
+    {
+        State.SetState(() => State.Content = child);
+    }
+
+    public void RemoveChild(ILayoutElement<Control> child)
+    {
+        State.SetState(() => State.Content = null);
+    }
+
     public IEnumerator<ILayoutElement<Control>> GetEnumerator()
     {
         yield return State.Content;

+ 75 - 2
src/PixiEditor.Extensions/LayoutBuilding/Elements/StatefulElement.cs

@@ -1,5 +1,6 @@
 using Avalonia.Controls;
 using Avalonia.Controls.Presenters;
+using PixiEditor.Extensions.CommonApi.LayoutBuilding;
 using PixiEditor.Extensions.CommonApi.LayoutBuilding.State;
 
 namespace PixiEditor.Extensions.LayoutBuilding.Elements;
@@ -8,6 +9,7 @@ public abstract class StatefulElement<TState> : LayoutElement, IStatefulElement<
 {
     private TState? _state;
     private ContentPresenter _presenter = null!;
+    private ILayoutElement<Control> _content = null!;
 
     IState<Control> IStatefulElement<Control>.State
     {
@@ -16,7 +18,11 @@ public abstract class StatefulElement<TState> : LayoutElement, IStatefulElement<
             if (_state == null)
             {
                 _state = CreateState();
-                _state.StateChanged += () => BuildNative();
+                _state.StateChanged += () =>
+                {
+                    var newState = State.Build();
+                    ContentFromLayout(newState);
+                };
             }
 
             return _state;
@@ -28,9 +34,76 @@ public abstract class StatefulElement<TState> : LayoutElement, IStatefulElement<
     public override Control BuildNative()
     {
         _presenter ??= new ContentPresenter();
-        _presenter.Content = State.Build().BuildNative();
+        _content = State.Build();
+        _presenter.Content = _content.BuildNative();
         return _presenter;
     }
 
     public abstract TState CreateState();
+
+    // TODO: Maybe we don't have to redraw whole tree if parent changed, just detach from old parent and attach to new one?
+    // React seems not to do that, so there might be a reason for that, however, it would be good to check out the topic more
+    public void ContentFromLayout(ILayoutElement<Control> newTree)
+    {
+        PerformDiff(_content, newTree);
+    }
+
+    private void PerformDiff(ILayoutElement<Control> oldNode, ILayoutElement<Control> newNode)
+    {
+        // Check if the node types are the same
+        bool isSameType = oldNode.GetType() == newNode.GetType();
+
+        if (isSameType)
+        {
+            ApplyProperties(newNode, oldNode);
+        }
+        else
+        {
+            // Replace the entire node if the types are different
+            oldNode = newNode;
+            return;
+        }
+
+        // Check if the node supports children
+        if (oldNode is IChildHost oldDeserializable && newNode is IChildHost newDeserializable)
+        {
+            // Perform diff for children
+            using var oldChildren = oldDeserializable.GetEnumerator();
+            using var newChildren = newDeserializable.GetEnumerator();
+
+            while (oldChildren.MoveNext() && newChildren.MoveNext())
+            {
+                PerformDiff(oldChildren.Current, newChildren.Current);
+            }
+
+            if (oldChildren.Current == null && newChildren.Current != null)
+            {
+                oldDeserializable.AddChild(newChildren.Current);
+            }
+            else if (oldChildren.Current != null && newChildren.Current == null)
+            {
+                oldDeserializable.RemoveChild(oldChildren.Current);
+            }
+
+            while (oldChildren.MoveNext())
+            {
+                oldDeserializable.RemoveChild(oldChildren.Current);
+            }
+
+            while (newChildren.MoveNext())
+            {
+                oldDeserializable.AddChild(newChildren.Current);
+            }
+        }
+    }
+
+    private void ApplyProperties(ILayoutElement<Control> from, ILayoutElement<Control> to)
+    {
+        if (to is IPropertyDeserializable propertyDeserializable && from is IPropertyDeserializable fromProps)
+        {
+            // TODO: Find a way to only apply changed properties, current solution shouldn't be a problem for most cases, but this
+            // might cause unnecessary redraws, binding fires and other stuff that might be expensive if we have a lot of elements
+            propertyDeserializable.DeserializeProperties(fromProps.GetProperties());
+        }
+    }
 }

+ 23 - 5
src/PixiEditor.Extensions/LayoutBuilding/Elements/Text.cs

@@ -1,10 +1,15 @@
-using Avalonia.Controls;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
 
 namespace PixiEditor.Extensions.LayoutBuilding.Elements;
 
 public class Text : StatelessElement, IPropertyDeserializable
 {
-    public string Value { get; set; }
+    private string _value = null!;
+    public string Value { get => _value; set => SetField(ref _value, value); }
     public Text(string value = "")
     {
         Value = value;
@@ -12,11 +17,24 @@ public class Text : StatelessElement, IPropertyDeserializable
 
     public override Control BuildNative()
     {
-        return new TextBlock { Text = Value };
+        TextBlock textBlock = new();
+        Binding binding = new()
+        {
+            Source = this,
+            Path = nameof(Value),
+        };
+
+        textBlock.Bind(TextBlock.TextProperty, binding);
+        return textBlock;
+    }
+
+    IEnumerable<object> IPropertyDeserializable.GetProperties()
+    {
+        yield return Value;
     }
 
-    void IPropertyDeserializable.DeserializeProperties(List<object> values)
+    void IPropertyDeserializable.DeserializeProperties(IEnumerable<object> values)
     {
-        Value = (string)values[0];
+        Value = (string)values.ElementAt(0);
     }
 }

+ 25 - 0
src/PixiEditor.Extensions/LayoutBuilding/LayoutElementToNativeControlConverter.cs

@@ -0,0 +1,25 @@
+using System.Globalization;
+using Avalonia.Controls;
+using Avalonia.Data.Converters;
+using PixiEditor.Extensions.CommonApi.LayoutBuilding;
+
+namespace PixiEditor.Extensions.LayoutBuilding;
+
+public class LayoutElementToNativeControlConverter : IValueConverter
+{
+    public static LayoutElementToNativeControlConverter Instance { get; } = new();
+    public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+    {
+        if (value is ILayoutElement<Control> element)
+        {
+            return element.BuildNative();
+        }
+
+        return null;
+    }
+
+    public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+    {
+        throw new NotImplementedException();
+    }
+}