diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eeeda7a..4b418dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,8 +5,6 @@ on: branches: [ "main", "2.0" ] pull_request: branches: [ "main", "2.0" ] - pull_request_target: - branches: [ "main", "2.0" ] workflow_dispatch: permissions: diff --git a/src/Ursa/Controls/Panels/WrapPanelWithTrailingItem.cs b/src/Ursa/Controls/Panels/WrapPanelWithTrailingItem.cs new file mode 100644 index 0000000..f26d193 --- /dev/null +++ b/src/Ursa/Controls/Panels/WrapPanelWithTrailingItem.cs @@ -0,0 +1,160 @@ +using System.Collections.Specialized; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Irihi.Avalonia.Shared.Helpers; + +namespace Ursa.Controls; + +public class WrapPanelWithTrailingItem : Panel +{ + public static readonly StyledProperty TrailingItemProperty = + AvaloniaProperty.Register( + nameof(TrailingItem)); + + public static readonly StyledProperty TrailingWrapWidthProperty = + AvaloniaProperty.Register( + nameof(TrailingWrapWidth)); + + static WrapPanelWithTrailingItem() + { + AffectsMeasure(TrailingItemProperty); + AffectsArrange(TrailingItemProperty); + } + + public Layoutable? TrailingItem + { + get => GetValue(TrailingItemProperty); + set => SetValue(TrailingItemProperty, value); + } + + public double TrailingWrapWidth + { + get => GetValue(TrailingWrapWidthProperty); + set => SetValue(TrailingWrapWidthProperty, value); + } + + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == TrailingItemProperty) + { + if (change.GetOldValue() is { } oldValue) + { + VisualChildren.Remove(oldValue); + if (!IsItemsHost) LogicalChildren.Remove(oldValue); + } + + if (change.GetNewValue() is { } newValue) + { + VisualChildren.Add(newValue); + if (!IsItemsHost) LogicalChildren.Add(newValue); + } + } + + WrapPanel p = new WrapPanel(); + } + + protected override Size MeasureOverride(Size availableSize) + { + double currentLineX = 0; + double currentLineHeight = 0; + double totalHeight = 0; + + var children = Children; + foreach (var child in children) + { + child.Measure(availableSize); + var deltaX = availableSize.Width - currentLineX; + // Width is enough to place next child + if (MathHelpers.GreaterThan(deltaX, child.DesiredSize.Width)) + { + currentLineX += child.DesiredSize.Width; + currentLineHeight = Math.Max(currentLineHeight, child.DesiredSize.Height); + } + // Width is not enough to place next child + // reset currentLineX and currentLineHeight + // accumulate last line height to total height. + // Notice: last line height accumulation only happens when restarting a new line, so it needs to finally add one more time outside iteration. + else + { + currentLineX = child.DesiredSize.Width; + totalHeight += currentLineHeight; + currentLineHeight = child.DesiredSize.Height; + } + } + + var last = TrailingItem; + if (last is null) return new Size(availableSize.Width, totalHeight); + last.Measure(availableSize); + var lastDeltaX = availableSize.Width - currentLineX; + // If width is not enough, add a new line, and recalculate total height + if (lastDeltaX < TrailingWrapWidth) + { + totalHeight += currentLineHeight; + totalHeight += last.DesiredSize.Height; + } + else + { + currentLineHeight = Math.Max(currentLineHeight, last.DesiredSize.Height); + totalHeight += currentLineHeight; + } + + return new Size(availableSize.Width, totalHeight); + } + + protected override Size ArrangeOverride(Size finalSize) + { + double currentLineX = 0; + double currentLineHeight = 0; + double totalHeight = 0; + var children = Children; + + foreach (var child in children) + { + double deltaX = finalSize.Width - currentLineX; + // Width is enough to place next child + if (MathHelpers.GreaterThan(deltaX, child.DesiredSize.Width)) + { + child.Arrange(new Rect(currentLineX, totalHeight, child.DesiredSize.Width, + Math.Max(child.DesiredSize.Height, currentLineHeight))); + currentLineX += child.Bounds.Width; + currentLineHeight = Math.Max(currentLineHeight, child.Bounds.Height); + } + // Width is not enough to place next child + // reset currentLineX and currentLineHeight + // accumulate last line height to total height. + // Notice: last line height accumulation only happens when restarting a new line, so it needs to finally add one more time outside iteration. + else + { + totalHeight += currentLineHeight; + child.Arrange(new Rect(0, totalHeight, Math.Min(child.DesiredSize.Width, finalSize.Width), + child.DesiredSize.Height)); + currentLineX = child.Bounds.Width; + currentLineHeight = child.Bounds.Height; + } + } + + var last = TrailingItem; + if (last is null) return new Size(finalSize.Width, totalHeight); + var lastDeltaX = finalSize.Width - currentLineX; + // If width is not enough, add a new line, and recalculate total height + if (lastDeltaX < TrailingWrapWidth) + { + totalHeight += currentLineHeight; + last.Arrange(new Rect(0, totalHeight, finalSize.Width, last.DesiredSize.Height)); + totalHeight += last.DesiredSize.Height; + } + else + { + currentLineHeight = children.Count == 1 ? finalSize.Height : currentLineHeight; + last.Arrange(new Rect(currentLineX, totalHeight, lastDeltaX, + Math.Max(currentLineHeight, last.DesiredSize.Height))); + currentLineHeight = Math.Max(currentLineHeight, last.DesiredSize.Height); + totalHeight += currentLineHeight; + } + + return new Size(finalSize.Width, totalHeight); + } +} \ No newline at end of file diff --git a/tests/HeadlessTest.Ursa/Controls/PanelTests/WrapPanelWithTrailingItemTests.cs b/tests/HeadlessTest.Ursa/Controls/PanelTests/WrapPanelWithTrailingItemTests.cs new file mode 100644 index 0000000..c1387a1 --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/PanelTests/WrapPanelWithTrailingItemTests.cs @@ -0,0 +1,155 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Layout; +using Avalonia.LogicalTree; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Ursa.Controls; + +namespace HeadlessTest.Ursa.Controls.PanelTests; + +public class WrapPanelWithTrailingItemTests +{ + [AvaloniaFact] + public void Visual_Children_Correct() + { + var panel = new WrapPanelWithTrailingItem(); + var child1 = new Button { Content = "Button 1"}; + var child2 = new Button { Content = "Button 2"}; + var trailing = new Button { Content = "Trailing"}; + + panel.Children.Add(child1); + panel.Children.Add(child2); + panel.TrailingItem = trailing; + + var visualChildren = panel.GetVisualChildren().ToList(); + Assert.Equal(3, visualChildren.Count); + Assert.Equal(child1, visualChildren[0]); + Assert.Equal(child2, visualChildren[1]); + Assert.Equal(trailing, visualChildren[2]); + + var child3 = new Button { Content = "Button 3"}; + panel.Children.Add(child3); + + visualChildren = panel.GetVisualChildren().ToList(); + Assert.Equal(4, visualChildren.Count); + Assert.Equal(child1, visualChildren[0]); + Assert.Equal(child2, visualChildren[1]); + Assert.Equal(child3, visualChildren[2]); + Assert.Equal(trailing, visualChildren[3]); + + var trailing2 = new Button { Content = "Trailing2"}; + panel.TrailingItem = trailing2; + + visualChildren = panel.GetVisualChildren().ToList(); + Assert.Equal(4, visualChildren.Count); + Assert.Equal(child1, visualChildren[0]); + Assert.Equal(child2, visualChildren[1]); + Assert.Equal(child3, visualChildren[2]); + Assert.Equal(trailing2, visualChildren[3]); + + panel.Children.Remove(child2); + + visualChildren = panel.GetVisualChildren().ToList(); + Assert.Equal(3, visualChildren.Count); + Assert.Equal(child1, visualChildren[0]); + Assert.Equal(child3, visualChildren[1]); + Assert.Equal(trailing2, visualChildren[2]); + } + + [AvaloniaFact] + // Items Appears in Logical Children because IsItemsHost is false for individual Panels + public void Logical_Children_Correct() + { + var panel = new WrapPanelWithTrailingItem(); + var child1 = new Button { Content = "Button 1"}; + var child2 = new Button { Content = "Button 2"}; + var trailing = new Button { Content = "Trailing"}; + + panel.Children.Add(child1); + panel.Children.Add(child2); + panel.TrailingItem = trailing; + + var logicalChildren = panel.GetLogicalChildren().ToList(); + Assert.Equal(3, logicalChildren.Count); + Assert.Equal(child1, logicalChildren[0]); + Assert.Equal(child2, logicalChildren[1]); + Assert.Equal(trailing, logicalChildren[2]); + + var child3 = new Button { Content = "Button 3"}; + panel.Children.Add(child3); + + logicalChildren = panel.GetLogicalChildren().ToList(); + Assert.Equal(4, logicalChildren.Count); + Assert.Equal(child1, logicalChildren[0]); + Assert.Equal(child2, logicalChildren[1]); + Assert.Equal(child3, logicalChildren[2]); + Assert.Equal(trailing, logicalChildren[3]); + + var trailing2 = new Button { Content = "Trailing2"}; + panel.TrailingItem = trailing2; + + logicalChildren = panel.GetLogicalChildren().ToList(); + Assert.Equal(4, logicalChildren.Count); + Assert.Equal(child1, logicalChildren[0]); + Assert.Equal(child2, logicalChildren[1]); + Assert.Equal(child3, logicalChildren[2]); + Assert.Equal(trailing2, logicalChildren[3]); + + panel.Children.Remove(child2); + + logicalChildren = panel.GetLogicalChildren().ToList(); + Assert.Equal(3, logicalChildren.Count); + Assert.Equal(child1, logicalChildren[0]); + Assert.Equal(child3, logicalChildren[1]); + Assert.Equal(trailing2, logicalChildren[2]); + } + + [AvaloniaFact] + public void Measure_Arrange_Children() + { + var window = new Window() + { + Height = 1000, Width = 1000, VerticalContentAlignment = VerticalAlignment.Stretch + }; + var panel = new WrapPanelWithTrailingItem() { TrailingWrapWidth = 30 }; + var child1 = new Button { Content = "Button 1", Width = 200, Height = 100 }; + var child2 = new Button { Content = "Button 2", Width = 300, Height = 100 }; + var trailing = new Button + { + Content = "Trailing", + Height = 100, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + window.Content = panel; + panel.Children.Add(child1); + panel.Children.Add(child2); + panel.TrailingItem = trailing; + + window.Show(); + Dispatcher.UIThread.RunJobs(); + Assert.Equal(200, child1.Bounds.Width); + Assert.Equal(300, child2.Bounds.Width); + Assert.Equal(500, trailing.Bounds.Width); + + panel.Width = 600; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(200, child1.Bounds.Width); + Assert.Equal(300, child2.Bounds.Width); + Assert.Equal(100, trailing.Bounds.Width); + + panel.Width = 510; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(200, child1.Bounds.Width); + Assert.Equal(300, child2.Bounds.Width); + Assert.Equal(510, trailing.Bounds.Width); + Assert.Equal(100, trailing.Bounds.Y); + + panel.Width = 300; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(200, child1.Bounds.Width); + Assert.Equal(300, child2.Bounds.Width); + Assert.Equal(300, trailing.Bounds.Width); + Assert.Equal(200, trailing.Bounds.Y); + } +} \ No newline at end of file