diff --git a/Ursa.sln b/Ursa.sln index 695f774..524363d 100644 --- a/Ursa.sln +++ b/Ursa.sln @@ -42,8 +42,8 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GitHub Action", "GitHub Action", "{66123AC1-7C8C-4AA0-BBDB-5CC3E647A741}" ProjectSection(SolutionItems) = preProject .github\workflows\deploy.yml = .github\workflows\deploy.yml - .github\workflows\pack.yml = .github\workflows\pack.yml .github\workflows\pack-nightly.yml = .github\workflows\pack-nightly.yml + .github\workflows\pack.yml = .github\workflows\pack.yml .github\workflows\publish.yml = .github\workflows\publish.yml .github\workflows\release-tag.yml = .github\workflows\release-tag.yml .github\workflows\test.yml = .github\workflows\test.yml @@ -69,6 +69,7 @@ Global {53B5F277-3AEB-4661-ACAE-15CFFF2ED800}.Release|Any CPU.Build.0 = Release|Any CPU {3FC76CD9-CE5D-4804-A8D6-4E292EB296AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3FC76CD9-CE5D-4804-A8D6-4E292EB296AA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FC76CD9-CE5D-4804-A8D6-4E292EB296AA}.Debug|Any CPU.Deploy.0 = Debug|Any CPU {3FC76CD9-CE5D-4804-A8D6-4E292EB296AA}.Release|Any CPU.ActiveCfg = Release|Any CPU {3FC76CD9-CE5D-4804-A8D6-4E292EB296AA}.Release|Any CPU.Build.0 = Release|Any CPU {B6BAB821-A9FE-44F3-B9CD-06E27FDB63F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU diff --git a/demo/Ursa.Demo/Pages/AnchorDemo.axaml b/demo/Ursa.Demo/Pages/AnchorDemo.axaml new file mode 100644 index 0000000..9f4c56b --- /dev/null +++ b/demo/Ursa.Demo/Pages/AnchorDemo.axaml @@ -0,0 +1,265 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/Ursa.Demo/Pages/AnchorDemo.axaml.cs b/demo/Ursa.Demo/Pages/AnchorDemo.axaml.cs new file mode 100644 index 0000000..584f821 --- /dev/null +++ b/demo/Ursa.Demo/Pages/AnchorDemo.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Ursa.Demo.Pages; + +public partial class AnchorDemo : UserControl +{ + public AnchorDemo() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/demo/Ursa.Demo/ViewModels/AnchorDemoViewModel.cs b/demo/Ursa.Demo/ViewModels/AnchorDemoViewModel.cs new file mode 100644 index 0000000..e5a4d05 --- /dev/null +++ b/demo/Ursa.Demo/ViewModels/AnchorDemoViewModel.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Ursa.Demo.ViewModels; + +public partial class AnchorDemoViewModel : ObservableObject +{ + public List AnchorItems { get; } = new() + { + new AnchorItemViewModel { AnchorId = "anchor1", Header = "Anchor 1" }, + new AnchorItemViewModel { AnchorId = "anchor2", Header = "Anchor 2" }, + new AnchorItemViewModel + { + AnchorId = "anchor3", Header = "Anchor 3", + Children = + [ + new AnchorItemViewModel() { AnchorId = "anchor3-1", Header = "Anchor 3.1" }, + new AnchorItemViewModel() + { + AnchorId = "anchor3-2", Header = "Anchor 3.2", + Children = + [ + new AnchorItemViewModel() { AnchorId = "anchor3-2-1", Header = "Anchor 3.2.1" }, + new AnchorItemViewModel() { AnchorId = "anchor3-2-2", Header = "Anchor 3.2.2" }, + new AnchorItemViewModel() { AnchorId = "anchor3-2-3", Header = "Anchor 3.2.3" } + ] + }, + new AnchorItemViewModel() { AnchorId = "anchor3-3", Header = "Anchor 3.3" } + ] + }, + new AnchorItemViewModel { AnchorId = "anchor4", Header = "Anchor 4" }, + new AnchorItemViewModel { AnchorId = "anchor5", Header = "Anchor 5" }, + new AnchorItemViewModel { AnchorId = "anchor6", Header = "Anchor 6" }, + new AnchorItemViewModel { AnchorId = "anchor7", Header = "Anchor 7" }, + }; +} + +public partial class AnchorItemViewModel : ObservableObject +{ + [ObservableProperty] private string? _anchorId; + [ObservableProperty] private string? _header; + public ObservableCollection? Children { get; set; } +} \ No newline at end of file diff --git a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs index 361b236..5f1ba3e 100644 --- a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs @@ -86,8 +86,9 @@ public partial class MainViewViewModel : ViewModelBase MenuKeys.MenuKeyToolBar => new ToolBarDemoViewModel(), MenuKeys.MenuKeyTreeComboBox => new TreeComboBoxDemoViewModel(), MenuKeys.MenuKeyTwoTonePathIcon => new TwoTonePathIconDemoViewModel(), - MenuKeys.AspectRatioLayout => new AspectRatioLayoutDemoViewModel(), - MenuKeys.PathPicker => new PathPickerDemoViewModel(), + MenuKeys.MenuKeyAspectRatioLayout => new AspectRatioLayoutDemoViewModel(), + MenuKeys.MenuKeyPathPicker => new PathPickerDemoViewModel(), + MenuKeys.MenuKeyAnchor => new AnchorDemoViewModel(), _ => throw new ArgumentOutOfRangeException(nameof(s), s, null) }; } diff --git a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs index 14f4e60..297822b 100644 --- a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs @@ -26,7 +26,7 @@ public class MenuViewModel : ViewModelBase new() { MenuHeader = "MultiComboBox", Key = MenuKeys.MenuKeyMultiComboBox }, new() { MenuHeader = "Numeric UpDown", Key = MenuKeys.MenuKeyNumericUpDown }, new() { MenuHeader = "NumPad", Key = MenuKeys.MenuKeyNumPad }, - new() { MenuHeader = "PathPicker", Key = MenuKeys.PathPicker, Status = "New" }, + new() { MenuHeader = "PathPicker", Key = MenuKeys.MenuKeyPathPicker, Status = "New" }, new() { MenuHeader = "PinCode", Key = MenuKeys.MenuKeyPinCode }, new() { MenuHeader = "RangeSlider", Key = MenuKeys.MenuKeyRangeSlider }, new() { MenuHeader = "Rating", Key = MenuKeys.MenuKeyRating }, @@ -67,6 +67,7 @@ public class MenuViewModel : ViewModelBase { MenuHeader = "Navigation & Menus", Children = new ObservableCollection { + new() { MenuHeader = "Anchor", Key = MenuKeys.MenuKeyAnchor, Status = "New" }, new() { MenuHeader = "Breadcrumb", Key = MenuKeys.MenuKeyBreadcrumb, Status = "Updated" }, new() { MenuHeader = "Nav Menu", Key = MenuKeys.MenuKeyNavMenu, Status = "Updated" }, new() { MenuHeader = "Pagination", Key = MenuKeys.MenuKeyPagination }, @@ -78,7 +79,7 @@ public class MenuViewModel : ViewModelBase MenuHeader = "Layout & Display", Children = new ObservableCollection { - new() { MenuHeader = "AspectRatioLayout", Key = MenuKeys.AspectRatioLayout }, + new() { MenuHeader = "AspectRatioLayout", Key = MenuKeys.MenuKeyAspectRatioLayout }, new() { MenuHeader = "Avatar", Key = MenuKeys.MenuKeyAvatar, Status = "WIP" }, new() { MenuHeader = "Badge", Key = MenuKeys.MenuKeyBadge }, new() { MenuHeader = "Banner", Key = MenuKeys.MenuKeyBanner, Status = "Updated" }, @@ -154,6 +155,7 @@ public static class MenuKeys public const string MenuKeyToolBar = "ToolBar"; public const string MenuKeyTreeComboBox = "TreeComboBox"; public const string MenuKeyTwoTonePathIcon = "TwoTonePathIcon"; - public const string AspectRatioLayout = "AspectRatioLayout"; - public const string PathPicker = "PathPicker"; + public const string MenuKeyAspectRatioLayout = "AspectRatioLayout"; + public const string MenuKeyPathPicker = "PathPicker"; + public const string MenuKeyAnchor = "Anchor"; } \ No newline at end of file diff --git a/src/Ursa.Themes.Semi/Controls/Anchor.axaml b/src/Ursa.Themes.Semi/Controls/Anchor.axaml new file mode 100644 index 0000000..87d292e --- /dev/null +++ b/src/Ursa.Themes.Semi/Controls/Anchor.axaml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ursa.Themes.Semi/Controls/_index.axaml b/src/Ursa.Themes.Semi/Controls/_index.axaml index ff8b078..b2672a9 100644 --- a/src/Ursa.Themes.Semi/Controls/_index.axaml +++ b/src/Ursa.Themes.Semi/Controls/_index.axaml @@ -1,5 +1,6 @@ + diff --git a/src/Ursa.Themes.Semi/Converters/NavigationMenuItemLevelToMarginConverter.cs b/src/Ursa.Themes.Semi/Converters/NavigationMenuItemLevelToMarginConverter.cs deleted file mode 100644 index bcacb57..0000000 --- a/src/Ursa.Themes.Semi/Converters/NavigationMenuItemLevelToMarginConverter.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Globalization; -using Avalonia; -using Avalonia.Data.Converters; - -namespace Ursa.Themes.Semi.Converters; - -public class NavigationMenuItemLevelToMarginConverter: IMultiValueConverter -{ - public int Indent { get; set; } - - public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) - { - if (values[0] is int i && values[1] is bool b) - { - if (b) - { - return new Thickness(); - } - return new Thickness((i-1) * Indent, 0, 0, 0); - } - return new Thickness(); - } -} \ No newline at end of file diff --git a/src/Ursa.Themes.Semi/Converters/TreeLevelToPaddingConverter.cs b/src/Ursa.Themes.Semi/Converters/TreeLevelToPaddingConverter.cs new file mode 100644 index 0000000..e99e9ea --- /dev/null +++ b/src/Ursa.Themes.Semi/Converters/TreeLevelToPaddingConverter.cs @@ -0,0 +1,18 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; + +namespace Ursa.Themes.Semi.Converters; + +public class TreeLevelToPaddingConverter : IMultiValueConverter +{ + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values[0] is int i && values[1] is Thickness indent) + { + return new Thickness(Math.Max(i, 0) * indent.Left, indent.Top, indent.Right, indent.Bottom); + } + + return new Thickness(); + } +} \ No newline at end of file diff --git a/src/Ursa.Themes.Semi/Themes/Dark/Anchor.axaml b/src/Ursa.Themes.Semi/Themes/Dark/Anchor.axaml new file mode 100644 index 0000000..a3e1e55 --- /dev/null +++ b/src/Ursa.Themes.Semi/Themes/Dark/Anchor.axaml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/Ursa.Themes.Semi/Themes/Dark/_index.axaml b/src/Ursa.Themes.Semi/Themes/Dark/_index.axaml index 0d5148a..8a8a444 100644 --- a/src/Ursa.Themes.Semi/Themes/Dark/_index.axaml +++ b/src/Ursa.Themes.Semi/Themes/Dark/_index.axaml @@ -1,6 +1,7 @@ + diff --git a/src/Ursa.Themes.Semi/Themes/HighContrast/Anchor.axaml b/src/Ursa.Themes.Semi/Themes/HighContrast/Anchor.axaml new file mode 100644 index 0000000..f79130a --- /dev/null +++ b/src/Ursa.Themes.Semi/Themes/HighContrast/Anchor.axaml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/Ursa.Themes.Semi/Themes/HighContrast/_index.axaml b/src/Ursa.Themes.Semi/Themes/HighContrast/_index.axaml index 2014115..bdde3cf 100644 --- a/src/Ursa.Themes.Semi/Themes/HighContrast/_index.axaml +++ b/src/Ursa.Themes.Semi/Themes/HighContrast/_index.axaml @@ -1,5 +1,6 @@ + diff --git a/src/Ursa.Themes.Semi/Themes/Light/Anchor.axaml b/src/Ursa.Themes.Semi/Themes/Light/Anchor.axaml new file mode 100644 index 0000000..a3e1e55 --- /dev/null +++ b/src/Ursa.Themes.Semi/Themes/Light/Anchor.axaml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/Ursa.Themes.Semi/Themes/Light/_index.axaml b/src/Ursa.Themes.Semi/Themes/Light/_index.axaml index 0d5148a..8a8a444 100644 --- a/src/Ursa.Themes.Semi/Themes/Light/_index.axaml +++ b/src/Ursa.Themes.Semi/Themes/Light/_index.axaml @@ -1,6 +1,7 @@ + diff --git a/src/Ursa.Themes.Semi/Themes/Shared/Anchor.axaml b/src/Ursa.Themes.Semi/Themes/Shared/Anchor.axaml new file mode 100644 index 0000000..1f56148 --- /dev/null +++ b/src/Ursa.Themes.Semi/Themes/Shared/Anchor.axaml @@ -0,0 +1,8 @@ + + 8,4,0,4 + 2 + 1 + 20 + 16 + 12 + \ No newline at end of file diff --git a/src/Ursa.Themes.Semi/Themes/Shared/_index.axaml b/src/Ursa.Themes.Semi/Themes/Shared/_index.axaml index 01c7e1a..7a665f6 100644 --- a/src/Ursa.Themes.Semi/Themes/Shared/_index.axaml +++ b/src/Ursa.Themes.Semi/Themes/Shared/_index.axaml @@ -1,6 +1,7 @@ + diff --git a/src/Ursa/Common/LogicalHelpers.cs b/src/Ursa/Common/LogicalHelpers.cs new file mode 100644 index 0000000..73b5f17 --- /dev/null +++ b/src/Ursa/Common/LogicalHelpers.cs @@ -0,0 +1,21 @@ +using Avalonia.LogicalTree; +using Ursa.Controls; + +namespace Ursa.Common; + +public static class LogicalHelpers +{ + public static int CalculateDistanceFromLogicalParent(TItem? item, int defaultValue = -1) + where T : class + where TItem : ILogical + { + var result = 0; + ILogical? logical = item; + while (logical is not null and not T) + { + if (logical is TItem) result++; + logical = logical.LogicalParent; + } + return item is not null ? result : defaultValue; + } +} \ No newline at end of file diff --git a/src/Ursa/Common/VisualHelpers.cs b/src/Ursa/Common/VisualHelpers.cs new file mode 100644 index 0000000..511a3d2 --- /dev/null +++ b/src/Ursa/Common/VisualHelpers.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.VisualTree; + +namespace Ursa.Common; + +public static class VisualHelpers +{ + public static T? GetContainerFromEventSource(this Visual? source) where T: Control + { + var item = source?.GetSelfAndVisualAncestors().OfType().FirstOrDefault(); + return item; + } +} \ No newline at end of file diff --git a/src/Ursa/Controls/Anchor/Anchor.cs b/src/Ursa/Controls/Anchor/Anchor.cs new file mode 100644 index 0000000..f184e8d --- /dev/null +++ b/src/Ursa/Controls/Anchor/Anchor.cs @@ -0,0 +1,221 @@ +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Styling; +using Avalonia.VisualTree; +using Ursa.Common; + +namespace Ursa.Controls; + +/// +/// Some basic assumptions: This should not be a regular SelectingItemsControl, because it does not support multiple +/// selections. +/// Selection should not be exposed to the user, it is only used to determine which item is currently selected. +/// The manipulation of container selection should be simplified. +/// Scroll event of TargetContainer also triggers selection change. +/// +public class Anchor : ItemsControl +{ + public static readonly StyledProperty TargetContainerProperty = + AvaloniaProperty.Register( + nameof(TargetContainer)); + + public static readonly AttachedProperty IdProperty = + AvaloniaProperty.RegisterAttached("Id"); + + private CancellationTokenSource _cts = new(); + + private List<(string, double)> _positions = []; + private bool _scrollingFromSelection; + + private AnchorItem? _selectedContainer; + + public ScrollViewer? TargetContainer + { + get => GetValue(TargetContainerProperty); + set => SetValue(TargetContainerProperty, value); + } + + public static void SetId(Visual obj, string? value) + { + obj.SetValue(IdProperty, value); + } + + public static string? GetId(Visual obj) + { + return obj.GetValue(IdProperty); + } + + public static readonly StyledProperty TopOffsetProperty = AvaloniaProperty.Register( + nameof(TopOffset)); + + public double TopOffset + { + get => GetValue(TopOffsetProperty); + set => SetValue(TopOffsetProperty, value); + } + + protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + return NeedsContainer(item, out recycleKey); + } + + protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + var i = new AnchorItem(); + return i; + } + + private void ScrollToAnchor(Visual target) + { + if (TargetContainer is null) + return; + + var targetPosition = target.TranslatePoint(new Point(0, 0), TargetContainer); + if (targetPosition.HasValue) + { + var from = TargetContainer.Offset.Y; + var to = TargetContainer.Offset.Y + targetPosition.Value.Y - TopOffset; + if (to > TargetContainer.Extent.Height - TargetContainer.Bounds.Height) + to = TargetContainer.Extent.Height - TargetContainer.Bounds.Height; + if (from == to) return; + var animation = new Animation + { + Duration = TimeSpan.FromSeconds(0.3), + Easing = new QuadraticEaseOut(), + Children = + { + new KeyFrame + { + Setters = { new Setter(ScrollViewer.OffsetProperty, new Vector(0, from)) }, + Cue = new Cue(0.0) + }, + new KeyFrame + { + Setters = { new Setter(ScrollViewer.OffsetProperty, new Vector(0, to)) }, + Cue = new Cue(1.0) + } + } + }; + _cts.Cancel(); + _cts.Dispose(); + _cts = new CancellationTokenSource(); + var token = _cts.Token; + token.Register(_ => _scrollingFromSelection = false, null); + _scrollingFromSelection = true; + animation.RunAsync(TargetContainer, token).ContinueWith(_ => _scrollingFromSelection = false, token); + } + } + + public void InvalidatePositions() + { + InvalidateAnchorPositions(); + MarkSelectedContainerByPosition(); + } + + internal void InvalidateAnchorPositions() + { + if (TargetContainer is null) return; + var items = TargetContainer.GetVisualDescendants().Where(a => GetId(a) is not null); + var positions = new List<(string, double)>(); + foreach (var item in items) + { + var anchorId = GetId(item); + if (anchorId is null) continue; + var position = item.TransformToVisual(TargetContainer)?.M32 + TargetContainer.Offset.Y; + if (position.HasValue) positions.Add((anchorId, position.Value)); + } + + positions.Sort((a, b) => a.Item2.CompareTo(b.Item2)); + _positions = positions; + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + var target = TargetContainer; + if (target is null) return; + TargetContainer?.AddHandler(ScrollViewer.ScrollChangedEvent, OnScrollChanged); + TargetContainer?.AddHandler(LoadedEvent, OnTargetContainerLoaded); + if (TargetContainer?.IsLoaded == true) InvalidateAnchorPositions(); + MarkSelectedContainerByPosition(); + } + + private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) + { + if (_scrollingFromSelection) return; + MarkSelectedContainerByPosition(); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + var source = (e.Source as Visual).GetContainerFromEventSource(); + if (source is null) return; + MarkSelectedContainer(source); + var target = TargetContainer?.GetVisualDescendants() + .FirstOrDefault(a => GetId(a) == source.AnchorId); + if (target is null) return; + ScrollToAnchor(target); + } + + /// + /// This method is used to expose the protected CreateContainerForItemOverride method to the AnchorItem class. + /// + internal Control CreateContainerForItemOverrideInternal(object? item, int index, object? recycleKey) + { + return CreateContainerForItemOverride(item, index, recycleKey); + } + + internal bool NeedsContainerOverrideInternal(object? item, int index, out object? recycleKey) + { + return NeedsContainerOverride(item, index, out recycleKey); + } + + internal void PrepareContainerForItemOverrideInternal(Control container, object? item, int index) + { + PrepareContainerForItemOverride(container, item, index); + } + + internal void ContainerForItemPreparedOverrideInternal(Control container, object? item, int index) + { + ContainerForItemPreparedOverride(container, item, index); + } + + internal void MarkSelectedContainer(AnchorItem? item) + { + var oldValue = _selectedContainer; + var newValue = item; + if (oldValue == newValue) return; + _selectedContainer?.SetValue(AnchorItem.IsSelectedProperty, false); + _selectedContainer = newValue; + _selectedContainer?.SetValue(AnchorItem.IsSelectedProperty, true); + } + + internal void MarkSelectedContainerByPosition() + { + if (TargetContainer is null) return; + var top = TargetContainer.Offset.Y + TopOffset; + var topAnchorId = _positions.LastOrDefault(a => a.Item2 <= top).Item1; + if (topAnchorId is null) return; + var item = this.GetVisualDescendants().OfType() + .FirstOrDefault(a => a.AnchorId == topAnchorId); + if (item is null) return; + MarkSelectedContainer(item); + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + base.OnUnloaded(e); + TargetContainer?.RemoveHandler(LoadedEvent, OnTargetContainerLoaded); + TargetContainer?.RemoveHandler(ScrollViewer.ScrollChangedEvent, OnScrollChanged); + } + + private void OnTargetContainerLoaded(object? sender, RoutedEventArgs e) + { + InvalidateAnchorPositions(); + } +} \ No newline at end of file diff --git a/src/Ursa/Controls/Anchor/AnchorItem.cs b/src/Ursa/Controls/Anchor/AnchorItem.cs new file mode 100644 index 0000000..6d680e6 --- /dev/null +++ b/src/Ursa/Controls/Anchor/AnchorItem.cs @@ -0,0 +1,91 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Mixins; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.LogicalTree; +using Ursa.Common; + +namespace Ursa.Controls; + +public class AnchorItem : HeaderedItemsControl, ISelectable +{ + public static readonly StyledProperty AnchorIdProperty = AvaloniaProperty.Register( + nameof(AnchorId)); + + public static readonly StyledProperty IsSelectedProperty = + SelectingItemsControl.IsSelectedProperty.AddOwner(); + + private static readonly FuncTemplate DefaultPanel = + new(() => new StackPanel()); + + internal static readonly DirectProperty LevelProperty = + AvaloniaProperty.RegisterDirect( + nameof(Level), o => o.Level, (o, v) => o.Level = v); + + private int _level; + + private Anchor? _root; + + static AnchorItem() + { + SelectableMixin.Attach(IsSelectedProperty); + PressedMixin.Attach(); + ItemsPanelProperty.OverrideDefaultValue(DefaultPanel); + } + + public int Level + { + get => _level; + set => SetAndRaise(LevelProperty, ref _level, value); + } + + public string? AnchorId + { + get => GetValue(AnchorIdProperty); + set => SetValue(AnchorIdProperty, value); + } + + public bool IsSelected + { + get => GetValue(IsSelectedProperty); + set => SetValue(IsSelectedProperty, value); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _root = this.GetLogicalAncestors().OfType().FirstOrDefault(); + Level = LogicalHelpers.CalculateDistanceFromLogicalParent(this); + if (ItemTemplate is null && _root?.ItemTemplate is not null) + SetCurrentValue(ItemTemplateProperty, _root.ItemTemplate); + + if (ItemContainerTheme is null && _root?.ItemContainerTheme is not null) + SetCurrentValue(ItemContainerThemeProperty, _root.ItemContainerTheme); + } + + protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return EnsureRoot().CreateContainerForItemOverrideInternal(item, index, recycleKey); + } + + protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + return EnsureRoot().NeedsContainerOverrideInternal(item, index, out recycleKey); + } + + protected override void PrepareContainerForItemOverride(Control container, object? item, int index) + { + EnsureRoot().PrepareContainerForItemOverrideInternal(container, item, index); + } + + protected override void ContainerForItemPreparedOverride(Control container, object? item, int index) + { + EnsureRoot().ContainerForItemPreparedOverrideInternal(container, item, index); + } + + private Anchor EnsureRoot() + { + return _root ?? throw new InvalidOperationException("AnchorItem must be inside an Anchor control."); + } +} \ No newline at end of file diff --git a/tests/HeadlessTest.Ursa/Controls/AnchorTests/TestView.axaml b/tests/HeadlessTest.Ursa/Controls/AnchorTests/TestView.axaml new file mode 100644 index 0000000..271ee63 --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/AnchorTests/TestView.axaml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/HeadlessTest.Ursa/Controls/AnchorTests/TestView.axaml.cs b/tests/HeadlessTest.Ursa/Controls/AnchorTests/TestView.axaml.cs new file mode 100644 index 0000000..80bce48 --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/AnchorTests/TestView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace HeadlessTest.Ursa.Controls.AnchorTests; + +public partial class TestView : UserControl +{ + public TestView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/tests/HeadlessTest.Ursa/Controls/AnchorTests/TestView2.axaml b/tests/HeadlessTest.Ursa/Controls/AnchorTests/TestView2.axaml new file mode 100644 index 0000000..70cb90c --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/AnchorTests/TestView2.axaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/tests/HeadlessTest.Ursa/Controls/AnchorTests/TestView2.axaml.cs b/tests/HeadlessTest.Ursa/Controls/AnchorTests/TestView2.axaml.cs new file mode 100644 index 0000000..23378af --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/AnchorTests/TestView2.axaml.cs @@ -0,0 +1,46 @@ +using System.Collections.ObjectModel; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace HeadlessTest.Ursa.Controls.AnchorTests; + +public partial class TestView2 : UserControl +{ + public TestView2() + { + InitializeComponent(); + DataContext = new AnchorDemoViewModel(); + } +} + +public partial class AnchorDemoViewModel: ObservableObject +{ + public List AnchorItems { get; } = new() + { + new AnchorItemViewModel { AnchorId = "anchor1", Header = "Anchor 1" }, + new AnchorItemViewModel { AnchorId = "anchor2", Header = "Anchor 2" }, + new AnchorItemViewModel + { + AnchorId = "anchor3", Header = "Anchor 3", + Children = + [ + new AnchorItemViewModel() { AnchorId = "anchor3-1", Header = "Anchor 3.1" }, + new AnchorItemViewModel() { AnchorId = "anchor3-2", Header = "Anchor 3.2" }, + new AnchorItemViewModel() { AnchorId = "anchor3-3", Header = "Anchor 3.3" } + ] + }, + new AnchorItemViewModel { AnchorId = "anchor4", Header = "Anchor 4" }, + new AnchorItemViewModel { AnchorId = "anchor5", Header = "Anchor 5" }, + new AnchorItemViewModel { AnchorId = "anchor6", Header = "Anchor 6" }, + new AnchorItemViewModel { AnchorId = "anchor7", Header = "Anchor 7" }, + }; +} + +public partial class AnchorItemViewModel: ObservableObject +{ + [ObservableProperty] private string? _anchorId; + [ObservableProperty] private string? _header; + public ObservableCollection? Children { get; set; } +} \ No newline at end of file diff --git a/tests/HeadlessTest.Ursa/Controls/AnchorTests/Tests.cs b/tests/HeadlessTest.Ursa/Controls/AnchorTests/Tests.cs new file mode 100644 index 0000000..2e57d4a --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/AnchorTests/Tests.cs @@ -0,0 +1,97 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Ursa.Controls; + +namespace HeadlessTest.Ursa.Controls.AnchorTests; + +public class Tests +{ + [AvaloniaFact] + public async void Click_Anchor_With_Mouse_Should_Update_Scroll_Offset() + { + var window = new Window() + { + Width = 500, + Height = 500, + }; + var view = new TestView(); + window.Content = view; + window.Show(); + + var anchor = view.FindControl("Anchor"); + var scrollViewer = view.FindControl("ScrollViewer"); + var item4 = view.FindControl("Item4"); + + Assert.NotNull(anchor); + Assert.NotNull(scrollViewer); + Assert.NotNull(item4); + + var transltion = item4.TranslatePoint(new Point(0, 0), window); + + Assert.Equal(0, scrollViewer.Offset.Y); + + // Simulate a click on the anchor + window.MouseDown(new Point(10, transltion.Value.Y+10), MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + await Task.Delay(800); + Assert.True(item4.IsSelected); + Assert.Equal(300.0 * 3, scrollViewer.Offset.Y, 50.0); + } + + [AvaloniaFact] + public async void Change_Scroll_Offset_Should_Update_Selected_Item() + { + var window = new Window() + { + Width = 500, + Height = 500, + }; + var view = new TestView(); + window.Content = view; + window.Show(); + + var anchor = view.FindControl("Anchor"); + var scrollViewer = view.FindControl("ScrollViewer"); + var item1 = view.FindControl("Item1"); + var item2 = view.FindControl("Item2"); + var item4 = view.FindControl("Item4"); + + Assert.NotNull(anchor); + Assert.NotNull(scrollViewer); + Assert.NotNull(item1); + Assert.NotNull(item2); + Assert.NotNull(item4); + + Dispatcher.UIThread.RunJobs(); + + Assert.True(item1.IsSelected); + Assert.False(item2.IsSelected); + + // Change the scroll offset + scrollViewer.Offset = new Vector(0, 310.0); + Dispatcher.UIThread.RunJobs(); + + // Check if the second item is selected + Assert.True(item2.IsSelected); + Assert.False(item4.IsSelected); + } + + [AvaloniaFact] + public void MVVM_Support() + { + var window = new Window(); + var view = new TestView2(); + window.Content = view; + window.Show(); + Dispatcher.UIThread.RunJobs(); + var items = window.GetVisualDescendants().OfType().ToList(); + Assert.NotEmpty(items); + Assert.Equal(10, items.Count); + + } +} \ No newline at end of file