diff --git a/demo/Ursa.Demo/Converters/IconNameToPathConverter.cs b/demo/Ursa.Demo/Converters/IconNameToPathConverter.cs new file mode 100644 index 0000000..5a602e8 --- /dev/null +++ b/demo/Ursa.Demo/Converters/IconNameToPathConverter.cs @@ -0,0 +1,36 @@ +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace Ursa.Demo.Converters; + +public class IconNameToPathConverter: IValueConverter +{ + private string[] paths = new[] + { + "M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z", + "M16 12L9 2L2 12H3.86L0 18H7V22H11V18H18L14.14 12H16M20.14 12H22L15 2L12.61 5.41L17.92 13H15.97L19.19 18H24L20.14 12M13 19H17V22H13V19Z", + "M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z", + "M5 21C3.9 21 3 20.1 3 19V5C3 3.9 3.9 3 5 3H19C20.1 3 21 3.9 21 5V19C21 20.1 20.1 21 19 21H5M15.3 16L13.2 13.9L17 10L14.2 7.2L10.4 11.1L8.2 8.9V16H15.3Z", + "M16,9V7H12V12.5C11.58,12.19 11.07,12 10.5,12A2.5,2.5 0 0,0 8,14.5A2.5,2.5 0 0,0 10.5,17A2.5,2.5 0 0,0 13,14.5V9H16M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2Z", + "M16.67,4H15V2H9V4H7.33A1.33,1.33 0 0,0 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67A1.33,1.33 0 0,0 18,20.67V5.33C18,4.6 17.4,4 16.67,4Z", + "M12 2C6.5 2 2 6.5 2 12C2 17.5 6.5 22 12 22C17.5 22 22 17.5 22 12S17.5 2 12 2M12.5 13H11V7H12.5V11.3L16.2 9.2L17 10.5L12.5 13Z" + }; + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int i) + { + string s = paths[i % paths.Length]; + return StreamGeometry.Parse(s); + } + return AvaloniaProperty.UnsetValue; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return AvaloniaProperty.UnsetValue; + } +} \ No newline at end of file diff --git a/demo/Ursa.Demo/Models/MenuKeys.cs b/demo/Ursa.Demo/Models/MenuKeys.cs index ea8f8f5..56e6a49 100644 --- a/demo/Ursa.Demo/Models/MenuKeys.cs +++ b/demo/Ursa.Demo/Models/MenuKeys.cs @@ -19,6 +19,7 @@ public static class MenuKeys public const string MenuKeyLoading = "Loading"; public const string MenuKeyMessageBox = "MessageBox"; public const string MenuKeyNavigation = "Navigation"; + public const string MenuKeyNavMenu = "NavMenu"; public const string MenuKeyNumericUpDown = "NumericUpDown"; public const string MenuKeyPagination = "Pagination"; public const string MenuKeyRangeSlider = "RangeSlider"; diff --git a/demo/Ursa.Demo/Pages/NavMenuDemo.axaml b/demo/Ursa.Demo/Pages/NavMenuDemo.axaml new file mode 100644 index 0000000..f1ac1cd --- /dev/null +++ b/demo/Ursa.Demo/Pages/NavMenuDemo.axaml @@ -0,0 +1,123 @@ + + + + + + + Collapse + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/Ursa.Demo/Pages/NavMenuDemo.axaml.cs b/demo/Ursa.Demo/Pages/NavMenuDemo.axaml.cs new file mode 100644 index 0000000..eac27af --- /dev/null +++ b/demo/Ursa.Demo/Pages/NavMenuDemo.axaml.cs @@ -0,0 +1,27 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.LogicalTree; +using Avalonia.Markup.Xaml; +using Ursa.Demo.ViewModels; + +namespace Ursa.Demo.Pages; + +public partial class NavMenuDemo : UserControl +{ + public NavMenuDemo() + { + InitializeComponent(); + this.DataContext = new NavMenuDemoViewModel(); + } + + private void InputElement_OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (sender is Rectangle c) + { + c.ApplyStyling(); + var ancestors = c.GetLogicalAncestors(); + } + } +} \ No newline at end of file diff --git a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs index 0a20fe2..43166bd 100644 --- a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs @@ -41,6 +41,7 @@ public class MainViewViewModel : ViewModelBase MenuKeys.MenuKeyLoading => new LoadingDemoViewModel(), MenuKeys.MenuKeyMessageBox => new MessageBoxDemoViewModel(), MenuKeys.MenuKeyNavigation => new NavigationMenuDemoViewModel(), + MenuKeys.MenuKeyNavMenu => new NavMenuDemoViewModel(), MenuKeys.MenuKeyNumericUpDown => new NumericUpDownDemoViewModel(), MenuKeys.MenuKeyPagination => new PaginationDemoViewModel(), MenuKeys.MenuKeyRangeSlider => new RangeSliderDemoViewModel(), diff --git a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs index 1377af9..6f6da25 100644 --- a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs @@ -28,6 +28,7 @@ public class MenuViewModel: ViewModelBase new() { MenuHeader = "Loading", Key = MenuKeys.MenuKeyLoading }, new() { MenuHeader = "Message Box", Key = MenuKeys.MenuKeyMessageBox, Status = "New" }, new() { MenuHeader = "Navigation", Key = MenuKeys.MenuKeyNavigation, Status = "WIP" }, + new() { MenuHeader = "Nav Menu", Key = MenuKeys.MenuKeyNavMenu, Status = "WIP"}, new() { MenuHeader = "NumericUpDown", Key = MenuKeys.MenuKeyNumericUpDown, Status = "New" }, new() { MenuHeader = "Pagination", Key = MenuKeys.MenuKeyPagination }, new() { MenuHeader = "RangeSlider", Key = MenuKeys.MenuKeyRangeSlider, Status = "New"}, diff --git a/demo/Ursa.Demo/ViewModels/NavMenuDemoViewModel.cs b/demo/Ursa.Demo/ViewModels/NavMenuDemoViewModel.cs new file mode 100644 index 0000000..66bec4c --- /dev/null +++ b/demo/Ursa.Demo/ViewModels/NavMenuDemoViewModel.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using System.Windows.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Ursa.Controls; +namespace Ursa.Demo.ViewModels; + +public class NavMenuDemoViewModel: ObservableObject +{ + private MenuItem? _selectedMenuItem; + + public MenuItem? SelectedMenuItem + { + get=>_selectedMenuItem; + set => SetProperty(ref _selectedMenuItem, value); + } + public ObservableCollection MenuItems { get; set; } = new ObservableCollection + { + new MenuItem { Header = "Introduction" , Children = + { + new MenuItem() { Header = "Getting Started", Children = + { + new MenuItem() { Header = "Code of Conduct" }, + new MenuItem() { Header = "How to Contribute" }, + new MenuItem() { Header = "Development Workflow" }, + }}, + new MenuItem() { Header = "Design Principles"}, + new MenuItem() { Header = "Contributing", Children = + { + new MenuItem() { Header = "Code of Conduct" }, + new MenuItem() { Header = "How to Contribute" }, + new MenuItem() { Header = "Development Workflow" }, + }}, + }}, + new MenuItem { Header = "Controls", IsSeparator = true}, + new MenuItem { Header = "Badge" }, + new MenuItem { Header = "Banner" }, + new MenuItem { Header = "ButtonGroup" }, + new MenuItem { Header = "Class Input" }, + new MenuItem { Header = "Dialog" }, + new MenuItem { Header = "Divider" }, + new MenuItem { Header = "Drawer" }, + new MenuItem { Header = "DualBadge" }, + new MenuItem { Header = "EnumSelector" }, + new MenuItem { Header = "ImageViewer" }, + new MenuItem { Header = "IPv4Box" }, + new MenuItem { Header = "IconButton" }, + new MenuItem { Header = "KeyGestureInput" }, + new MenuItem { Header = "Loading" }, + new MenuItem { Header = "MessageBox" }, + new MenuItem { Header = "Navigation" }, + new MenuItem { Header = "NavMenu" }, + new MenuItem { Header = "NumericUpDown" }, + new MenuItem { Header = "Pagination" }, + new MenuItem { Header = "RangeSlider" }, + new MenuItem { Header = "SelectionList" }, + new MenuItem { Header = "TagInput" }, + new MenuItem { Header = "Timeline" }, + new MenuItem { Header = "TwoTonePathIcon" }, + new MenuItem { Header = "ThemeToggler" } + }; + + public ICommand RandomCommand { get; set; } + public NavMenuDemoViewModel() + { + RandomCommand = new RelayCommand(OnRandom); + } + + private void OnRandom() + { + var items = GetLeaves(); + var index = new Random().Next(items.Count); + SelectedMenuItem = items[index]; + } + + private List GetLeaves() + { + List items = new(); + foreach (var item in MenuItems) + { + items.AddRange(item.GetLeaves()); + } + + return items; + } +} + +public class MenuItem +{ + static Random r = new Random(); + + public string? Header { get; set; } + public int IconIndex { get; set; } + public bool IsSeparator { get; set; } + public ICommand NavigationCommand { get; set; } + + public MenuItem() + { + NavigationCommand = new AsyncRelayCommand(OnNavigate); + IconIndex = r.Next(100); + } + + private async Task OnNavigate() + { + await MessageBox.ShowOverlayAsync(Header??string.Empty, "Navigation Result"); + } + + public ObservableCollection Children { get; set; } = new ObservableCollection(); + + public IEnumerable GetLeaves() + { + if (this.Children.Count == 0) + { + yield return this; + yield break; + } + + foreach (var child in Children) + { + var items = child.GetLeaves(); + foreach (var item in items) + { + yield return item; + } + } + } +} \ No newline at end of file diff --git a/src/Ursa.Themes.Semi/Controls/NavMenu.axaml b/src/Ursa.Themes.Semi/Controls/NavMenu.axaml new file mode 100644 index 0000000..b8196a7 --- /dev/null +++ b/src/Ursa.Themes.Semi/Controls/NavMenu.axaml @@ -0,0 +1,222 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ursa.Themes.Semi/Controls/_index.axaml b/src/Ursa.Themes.Semi/Controls/_index.axaml index 4bd0c67..0b2dae5 100644 --- a/src/Ursa.Themes.Semi/Controls/_index.axaml +++ b/src/Ursa.Themes.Semi/Controls/_index.axaml @@ -18,6 +18,7 @@ + diff --git a/src/Ursa.Themes.Semi/Converters/NavMenuMarginConverter.cs b/src/Ursa.Themes.Semi/Converters/NavMenuMarginConverter.cs new file mode 100644 index 0000000..89e851b --- /dev/null +++ b/src/Ursa.Themes.Semi/Converters/NavMenuMarginConverter.cs @@ -0,0 +1,17 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; + +namespace Ursa.Themes.Semi.Converters; + +public class NavMenuMarginConverter: IMultiValueConverter +{ + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values[0] is double indent && values[1] is int level && values[2] is bool b) + { + return b ? new Thickness() : new Thickness(indent * (level-1), 0, 0, 0); + } + return AvaloniaProperty.UnsetValue; + } +} \ No newline at end of file diff --git a/src/Ursa/Controls/Icons/TwoTonePathIcon.cs b/src/Ursa/Controls/Icons/TwoTonePathIcon.cs index d44ad7c..6ee1267 100644 --- a/src/Ursa/Controls/Icons/TwoTonePathIcon.cs +++ b/src/Ursa/Controls/Icons/TwoTonePathIcon.cs @@ -5,6 +5,7 @@ using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; using Avalonia.Data; using Avalonia.Media; +using Irihi.Avalonia.Shared.Helpers; namespace Ursa.Controls; @@ -75,13 +76,7 @@ public class TwoTonePathIcon: TemplatedControl ForegroundProperty, ActiveForegroundProperty, ActiveStrokeBrushProperty); - IsActiveProperty.Changed.AddClassHandler((o, e) => o.OnIsActiveChanged(e)); - } - - private void OnIsActiveChanged(AvaloniaPropertyChangedEventArgs args) - { - var newValue = args.NewValue.Value; - PseudoClasses.Set(PC_Active, newValue); + PropertyToPseudoClassMixin.Attach(IsActiveProperty, PC_Active); } protected override void OnApplyTemplate(TemplateAppliedEventArgs e) diff --git a/src/Ursa/Controls/NavMenu/NavMenu.cs b/src/Ursa/Controls/NavMenu/NavMenu.cs new file mode 100644 index 0000000..44d73f8 --- /dev/null +++ b/src/Ursa/Controls/NavMenu/NavMenu.cs @@ -0,0 +1,276 @@ +using System.Diagnostics; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.Metadata; +using Irihi.Avalonia.Shared.Helpers; + +namespace Ursa.Controls; + +[PseudoClasses(PC_HorizontalCollapsed)] +public class NavMenu: ItemsControl +{ + public const string PC_HorizontalCollapsed = ":horizontal-collapsed"; + + public static readonly StyledProperty SelectedItemProperty = AvaloniaProperty.Register( + nameof(SelectedItem), defaultBindingMode: BindingMode.TwoWay); + + public object? SelectedItem + { + get => GetValue(SelectedItemProperty); + set => SetValue(SelectedItemProperty, value); + } + + public static readonly StyledProperty IconBindingProperty = AvaloniaProperty.Register( + nameof(IconBinding)); + + [AssignBinding] + [InheritDataTypeFromItems(nameof(ItemsSource))] + public IBinding? IconBinding + { + get => GetValue(IconBindingProperty); + set => SetValue(IconBindingProperty, value); + } + + public static readonly StyledProperty HeaderBindingProperty = AvaloniaProperty.Register( + nameof(HeaderBinding)); + + [AssignBinding] + [InheritDataTypeFromItems(nameof(ItemsSource))] + public IBinding? HeaderBinding + { + get => GetValue(HeaderBindingProperty); + set => SetValue(HeaderBindingProperty, value); + } + + public static readonly StyledProperty SubMenuBindingProperty = AvaloniaProperty.Register( + nameof(SubMenuBinding)); + + [AssignBinding] + [InheritDataTypeFromItems(nameof(ItemsSource))] + public IBinding? SubMenuBinding + { + get => GetValue(SubMenuBindingProperty); + set => SetValue(SubMenuBindingProperty, value); + } + + public static readonly StyledProperty CommandBindingProperty = AvaloniaProperty.Register( + nameof(CommandBinding)); + + [AssignBinding] + [InheritDataTypeFromItems(nameof(ItemsSource))] + public IBinding? CommandBinding + { + get => GetValue(CommandBindingProperty); + set => SetValue(CommandBindingProperty, value); + } + + public static readonly StyledProperty HeaderTemplateProperty = AvaloniaProperty.Register( + nameof(HeaderTemplate)); + + /// + /// Header Template is used for MenuItem headers, not menu header. + /// + public IDataTemplate? HeaderTemplate + { + get => GetValue(HeaderTemplateProperty); + set => SetValue(HeaderTemplateProperty, value); + } + + public static readonly StyledProperty IconTemplateProperty = AvaloniaProperty.Register( + nameof(IconTemplate)); + + public IDataTemplate? IconTemplate + { + get => GetValue(IconTemplateProperty); + set => SetValue(IconTemplateProperty, value); + } + + public static readonly StyledProperty SubMenuIndentProperty = AvaloniaProperty.Register( + nameof(SubMenuIndent)); + + public double SubMenuIndent + { + get => GetValue(SubMenuIndentProperty); + set => SetValue(SubMenuIndentProperty, value); + } + + public static readonly StyledProperty IsHorizontalCollapsedProperty = AvaloniaProperty.Register( + nameof(IsHorizontalCollapsed)); + + public bool IsHorizontalCollapsed + { + get => GetValue(IsHorizontalCollapsedProperty); + set => SetValue(IsHorizontalCollapsedProperty, value); + } + + public static readonly StyledProperty HeaderProperty = + HeaderedContentControl.HeaderProperty.AddOwner(); + + public object? Header + { + get => GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + public static readonly StyledProperty FooterProperty = AvaloniaProperty.Register( + nameof(Footer)); + + public object? Footer + { + get => GetValue(FooterProperty); + set => SetValue(FooterProperty, value); + } + + public static readonly AttachedProperty CanToggleProperty = + AvaloniaProperty.RegisterAttached("CanToggle"); + + public static void SetCanToggle(InputElement obj, bool value) => obj.SetValue(CanToggleProperty, value); + public static bool GetCanToggle(InputElement obj) => obj.GetValue(CanToggleProperty); + + public static readonly RoutedEvent SelectionChangedEvent = RoutedEvent.Register(nameof(SelectionChanged), RoutingStrategies.Bubble); + + public event EventHandler? SelectionChanged + { + add => AddHandler(SelectionChangedEvent, value); + remove => RemoveHandler(SelectionChangedEvent, value); + } + + static NavMenu() + { + SelectedItemProperty.Changed.AddClassHandler((o, e) => o.OnSelectedItemChange(e)); + PropertyToPseudoClassMixin.Attach(IsHorizontalCollapsedProperty, PC_HorizontalCollapsed); + CanToggleProperty.Changed.AddClassHandler(OnInputRegisteredAsToggle); + } + + private static void OnInputRegisteredAsToggle(InputElement input, AvaloniaPropertyChangedEventArgs e) + { + if (e.NewValue.Value) + { + input.AddHandler(PointerPressedEvent, OnElementToggle); + } + else + { + input.RemoveHandler(PointerPressedEvent, OnElementToggle); + } + } + + private static void OnElementToggle(object? sender, RoutedEventArgs args) + { + if (sender is not InputElement input) return; + var nav = input.FindLogicalAncestorOfType(); + if(nav is null) return; + bool collapsed = nav.IsHorizontalCollapsed; + nav.IsHorizontalCollapsed = !collapsed; + } + + /// + /// this implementation only works in the case that only leaf menu item is allowed to select. It will be changed if we introduce parent level selection in the future. + /// + /// + private void OnSelectedItemChange(AvaloniaPropertyChangedEventArgs args) + { + SelectionChangedEventArgs a = new SelectionChangedEventArgs( + SelectionChangedEvent, + new [] { args.OldValue.Value }, + new [] { args.NewValue.Value }); + if (_updateFromUI) + { + RaiseEvent(a); + return; + } + var newValue = args.NewValue.Value; + if (newValue is null) + { + ClearAll(); + RaiseEvent(a); + return; + } + var leaves = GetLeafMenus(); + bool found = false; + foreach (var leaf in leaves) + { + if (leaf == newValue || leaf.DataContext == newValue) + { + leaf.SelectItem(leaf); + found = true; + } + } + if (!found) + { + ClearAll(); + } + RaiseEvent(a); + } + + 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) + { + return new NavMenuItem(); + } + + private bool _updateFromUI; + + internal void SelectItem(NavMenuItem item, NavMenuItem parent) + { + _updateFromUI = true; + foreach (var child in LogicalChildren) + { + if (child == parent) + { + continue; + } + if (child is NavMenuItem navMenuItem) + { + navMenuItem.ClearSelection(); + } + } + if (item.DataContext is not null && item.DataContext != this.DataContext) + { + SelectedItem = item.DataContext; + } + else + { + SelectedItem = item; + } + item.BringIntoView(); + _updateFromUI = false; + } + + private IEnumerable GetLeafMenus() + { + foreach (var child in LogicalChildren) + { + if (child is NavMenuItem item) + { + var leafs = item.GetLeafMenus(); + foreach (var leaf in leafs) + { + yield return leaf; + } + } + } + } + + private void ClearAll() + { + foreach (var child in LogicalChildren) + { + if (child is NavMenuItem item) + { + item.ClearSelection(); + } + } + } +} \ No newline at end of file diff --git a/src/Ursa/Controls/NavMenu/NavMenuItem.cs b/src/Ursa/Controls/NavMenu/NavMenuItem.cs new file mode 100644 index 0000000..e1c31d3 --- /dev/null +++ b/src/Ursa/Controls/NavMenu/NavMenuItem.cs @@ -0,0 +1,352 @@ +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Mixins; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.VisualTree; +using Irihi.Avalonia.Shared.Helpers; + +namespace Ursa.Controls; + +/// +/// Navigation Menu Item +/// +[PseudoClasses(PC_Highlighted, PC_HorizontalCollapsed, PC_VerticalCollapsed, PC_FirstLevel, PC_Selector)] +public class NavMenuItem: HeaderedItemsControl +{ + public const string PC_Highlighted = ":highlighted"; + public const string PC_FirstLevel = ":first-level"; + public const string PC_HorizontalCollapsed = ":horizontal-collapsed"; + public const string PC_VerticalCollapsed = ":vertical-collapsed"; + public const string PC_Selector = ":selector"; + + private NavMenu? _rootMenu; + private Panel? _popupPanel; + private Popup? _popup; + private Panel? _overflowPanel; + + public static readonly StyledProperty IconProperty = AvaloniaProperty.Register( + nameof(Icon)); + + public object? Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public static readonly StyledProperty IconTemplateProperty = AvaloniaProperty.Register( + nameof(IconTemplate)); + + public IDataTemplate? IconTemplate + { + get => GetValue(IconTemplateProperty); + set => SetValue(IconTemplateProperty, value); + } + + public static readonly StyledProperty CommandProperty = Button.CommandProperty.AddOwner(); + + public ICommand? Command + { + get => GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + public static readonly StyledProperty CommandParameterProperty = + Button.CommandParameterProperty.AddOwner(); + + public object? CommandParameter + { + get => GetValue(CommandParameterProperty); + set => SetValue(CommandParameterProperty, value); + } + + public static readonly StyledProperty IsSelectedProperty = + SelectingItemsControl.IsSelectedProperty.AddOwner(); + + public bool IsSelected + { + get => GetValue(IsSelectedProperty); + set => SetValue(IsSelectedProperty, value); + } + + public static readonly RoutedEvent IsSelectedChangedEvent = RoutedEvent.Register("IsSelectedChanged", RoutingStrategies.Bubble); + + private bool _isHighlighted; + + public static readonly DirectProperty IsHighlightedProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsHighlighted), o => o.IsHighlighted, (o, v) => o.IsHighlighted = v, + defaultBindingMode: BindingMode.TwoWay); + + public bool IsHighlighted + { + get => _isHighlighted; + private set => SetAndRaise(IsHighlightedProperty, ref _isHighlighted, value); + } + + public static readonly StyledProperty IsHorizontalCollapsedProperty = + NavMenu.IsHorizontalCollapsedProperty.AddOwner(); + + public bool IsHorizontalCollapsed + { + get => GetValue(IsHorizontalCollapsedProperty); + set => SetValue(IsHorizontalCollapsedProperty, value); + } + + public static readonly StyledProperty IsVerticalCollapsedProperty = AvaloniaProperty.Register( + nameof(IsVerticalCollapsed)); + + public bool IsVerticalCollapsed + { + get => GetValue(IsVerticalCollapsedProperty); + set => SetValue(IsVerticalCollapsedProperty, value); + } + + public static readonly StyledProperty SubMenuIndentProperty = + NavMenu.SubMenuIndentProperty.AddOwner(); + + public double SubMenuIndent + { + get => GetValue(SubMenuIndentProperty); + set => SetValue(SubMenuIndentProperty, value); + } + + internal static readonly DirectProperty LevelProperty = AvaloniaProperty.RegisterDirect( + nameof(Level), o => o.Level, (o, v) => o.Level = v); + private int _level; + internal int Level + { + get => _level; + set => SetAndRaise(LevelProperty, ref _level, value); + } + + public static readonly StyledProperty IsSeparatorProperty = AvaloniaProperty.Register( + nameof(IsSeparator)); + + public bool IsSeparator + { + get => GetValue(IsSeparatorProperty); + set => SetValue(IsSeparatorProperty, value); + } + + static NavMenuItem() + { + // SelectableMixin.Attach(IsSelectedProperty); + PressedMixin.Attach(); + LevelProperty.Changed.AddClassHandler((item, args) => item.OnLevelChange(args)); + PropertyToPseudoClassMixin.Attach(IsHighlightedProperty, PC_Highlighted); + PropertyToPseudoClassMixin.Attach(IsHorizontalCollapsedProperty, PC_HorizontalCollapsed); + PropertyToPseudoClassMixin.Attach(IsVerticalCollapsedProperty, PC_VerticalCollapsed); + PropertyToPseudoClassMixin.Attach(IsSelectedProperty, ":selected", IsSelectedChangedEvent); + IsHorizontalCollapsedProperty.Changed.AddClassHandler((item, args) => + item.OnIsHorizontalCollapsedChanged(args)); + } + + private void OnIsHorizontalCollapsedChanged(AvaloniaPropertyChangedEventArgs args) + { + if (args.NewValue.Value) + { + if (this.ItemsPanelRoot is OverflowStackPanel s) + { + s.MoveChildrenToOverflowPanel(); + } + } + else + { + if (this.ItemsPanelRoot is OverflowStackPanel s) + { + s.MoveChildrenToMainPanel(); + } + } + } + + private void OnLevelChange(AvaloniaPropertyChangedEventArgs args) + { + PseudoClasses.Set(PC_FirstLevel, args.NewValue.Value == 1); + } + + 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) + { + return new NavMenuItem(); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _rootMenu = GetRootMenu(); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + SetCurrentValue(LevelProperty,CalculateDistanceFromLogicalParent(this)); + _popup = e.NameScope.Find("PART_Popup"); + _overflowPanel = e.NameScope.Find("PART_OverflowPanel"); + if (_rootMenu is not null) + { + if (_rootMenu.IconBinding is not null) + { + this[!IconProperty] = _rootMenu.IconBinding; + } + if (_rootMenu.HeaderBinding is not null) + { + this[!HeaderProperty] = _rootMenu.HeaderBinding; + } + if (_rootMenu.SubMenuBinding is not null) + { + this[!ItemsSourceProperty] = _rootMenu.SubMenuBinding; + } + if (_rootMenu.CommandBinding is not null) + { + this[!CommandProperty] = _rootMenu.CommandBinding; + } + this[!IconTemplateProperty] = _rootMenu[!NavMenu.IconTemplateProperty]; + this[!HeaderTemplateProperty] = _rootMenu[!NavMenu.HeaderTemplateProperty]; + this[!SubMenuIndentProperty] = _rootMenu[!NavMenu.SubMenuIndentProperty]; + this[!IsHorizontalCollapsedProperty] = _rootMenu[!NavMenu.IsHorizontalCollapsedProperty]; + } + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + var root = this.ItemsPanelRoot; + if (root is OverflowStackPanel stack) + { + stack.OverflowPanel = _overflowPanel; + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (IsSeparator) + { + e.Handled = true; + return; + } + base.OnPointerPressed(e); + if (this.ItemCount == 0) + { + SelectItem(this); + } + else + { + if (!IsHorizontalCollapsed) + { + SetCurrentValue(IsVerticalCollapsedProperty, !IsVerticalCollapsed); + } + else + { + if (_popup is not null) + { + if (_popup.IsOpen) + { + _popup.Close(); + } + else + { + _popup.Open(); + } + } + } + } + Command?.Execute(CommandParameter); + e.Handled = true; + } + + internal void SelectItem(NavMenuItem item) + { + if (item == this) + { + SetCurrentValue(IsSelectedProperty, true); + SetCurrentValue(IsHighlightedProperty, true); + } + else + { + SetCurrentValue(IsSelectedProperty, false); + SetCurrentValue(IsHighlightedProperty, true); + } + if (this.Parent is NavMenuItem menuItem) + { + menuItem.SelectItem(item); + var items = menuItem.LogicalChildren.OfType(); + foreach (var child in items) + { + if (child != this) + { + child.ClearSelection(); + } + } + } + else if (this.Parent is NavMenu menu) + { + menu.SelectItem(item, this); + } + } + + internal void ClearSelection() + { + SetCurrentValue(IsHighlightedProperty, false); + SetCurrentValue(IsSelectedProperty, false); + foreach (var child in LogicalChildren) + { + if (child is NavMenuItem item) + { + item.ClearSelection(); + } + } + } + + private NavMenu? GetRootMenu() + { + var root = this.FindAncestorOfType() ?? this.FindLogicalAncestorOfType(); + return root; + } + + private static int CalculateDistanceFromLogicalParent(ILogical? logical, int @default = -1) where T : class + { + var result = 0; + + while (logical != null && !(logical is T)) + { + if (logical is NavMenuItem) + { + result++; + } + logical = logical.LogicalParent; + } + + return logical != null ? result : @default; + } + + internal IEnumerable GetLeafMenus() + { + if (this.ItemCount == 0) + { + yield return this; + yield break; + } + foreach (var child in LogicalChildren) + { + if (child is NavMenuItem item) + { + var items = item.GetLeafMenus(); + foreach (var i in items) + { + yield return i; + } + } + } + } +} \ No newline at end of file diff --git a/src/Ursa/Controls/NavMenu/OverflowStackPanel.cs b/src/Ursa/Controls/NavMenu/OverflowStackPanel.cs new file mode 100644 index 0000000..bfa7ad1 --- /dev/null +++ b/src/Ursa/Controls/NavMenu/OverflowStackPanel.cs @@ -0,0 +1,30 @@ +using Avalonia.Controls; + +namespace Ursa.Controls; + +public class OverflowStackPanel: StackPanel +{ + public Panel? OverflowPanel { get; set; } + public void MoveChildrenToOverflowPanel() + { + var children = this.Children.ToList(); + foreach (var child in children) + { + Children.Remove(child); + OverflowPanel?.Children.Add(child); + } + } + + public void MoveChildrenToMainPanel() + { + var children = this.OverflowPanel?.Children.ToList(); + if (children != null && children.Count > 0) + { + foreach (var child in children) + { + OverflowPanel?.Children.Remove(child); + Children.Add(child); + } + } + } +} \ No newline at end of file diff --git a/src/Ursa/Ursa.csproj b/src/Ursa/Ursa.csproj index 002c019..ec98fe6 100644 --- a/src/Ursa/Ursa.csproj +++ b/src/Ursa/Ursa.csproj @@ -14,7 +14,7 @@ - +