From 83c5ec64a37f4ed456b79e5721b79528c11d9e7f Mon Sep 17 00:00:00 2001 From: ghudulf <39195631+ghudulf@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:59:37 +0300 Subject: [PATCH 1/4] Update publish condition in build workflow --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6dce3d64..dbc87aff 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -82,7 +82,7 @@ jobs: publish: runs-on: ubuntu-latest needs: build - if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') + if: github.event_name == 'release' || github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') timeout-minutes: 15 permissions: id-token: write From 2bb98955d599bc172c6569e0d39fa5bf9f5434c6 Mon Sep 17 00:00:00 2001 From: Andrey Savich <46422808+pieckenst@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:17:33 +0300 Subject: [PATCH 2/4] ci: Add step success checks to release workflow and update NavigationView - Add success condition checks to all AI processing steps in release workflow to prevent saving responses from failed steps - Update NavigationViewDistance property to use proper getter/setter instead of read-only accessor - Add NavigationViewSubMenuControl and SmoothScrollViewer fields to NavigationViewItem - Import Avalonia.Controls.Presenters namespace for presenter support - Bump package version to 5.1.3-alpha2fix - Prevents workflow errors when AI steps fail by validating step conclusion status before file operations --- .github/workflows/release.yml | 22 +-- build/Package.props | 2 +- .../NavigationView/NavigationViewItem.cs | 131 ++++++++++++-- .../NavigationViewSubMenuControl.cs | 167 ++++++++++++++++++ .../PleasantControls/NavigationViewItem.axaml | 18 +- 5 files changed, 306 insertions(+), 34 deletions(-) create mode 100644 src/PleasantUI/Controls/NavigationView/NavigationViewSubMenuControl.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34f7b387..a971ef43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -322,7 +322,7 @@ jobs: prompt-file: /tmp/prompts/prompt-1.txt - name: Save pass 1 response - if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' + if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass1.conclusion == 'success' run: | cp "${{ steps.ai_pass1.outputs.response-file }}" /tmp/pass-1.txt sleep 65 @@ -338,7 +338,7 @@ jobs: prompt-file: /tmp/prompts/prompt-2.txt - name: Save pass 2 response - if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' + if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass2.conclusion == 'success' run: | cp "${{ steps.ai_pass2.outputs.response-file }}" /tmp/pass-2.txt sleep 65 @@ -354,7 +354,7 @@ jobs: prompt-file: /tmp/prompts/prompt-3.txt - name: Save pass 3 response - if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' + if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass3.conclusion == 'success' run: | cp "${{ steps.ai_pass3.outputs.response-file }}" /tmp/pass-3.txt sleep 65 @@ -370,7 +370,7 @@ jobs: prompt-file: /tmp/prompts/prompt-4.txt - name: Save pass 4 response - if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' + if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass4.conclusion == 'success' run: | cp "${{ steps.ai_pass4.outputs.response-file }}" /tmp/pass-4.txt sleep 65 @@ -386,7 +386,7 @@ jobs: prompt-file: /tmp/prompts/prompt-5.txt - name: Save pass 5 response - if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' + if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass5.conclusion == 'success' run: | cp "${{ steps.ai_pass5.outputs.response-file }}" /tmp/pass-5.txt sleep 65 @@ -402,7 +402,7 @@ jobs: prompt-file: /tmp/prompts/prompt-6.txt - name: Save pass 6 response - if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' + if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' && steps.ai_pass6.conclusion == 'success' run: | cp "${{ steps.ai_pass6.outputs.response-file }}" /tmp/pass-6.txt sleep 65 @@ -446,7 +446,7 @@ jobs: prompt-file: /tmp/prompt-merge-a.txt - name: Save merge-A response - if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' + if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' && steps.ai_merge_a.conclusion == 'success' run: | cp "${{ steps.ai_merge_a.outputs.response-file }}" /tmp/merge-a.txt sleep 65 @@ -479,7 +479,7 @@ jobs: prompt-file: /tmp/prompt-compress-a.txt - name: Save compress-A response - if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' + if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' && steps.ai_compress_a.conclusion == 'success' run: | cp "${{ steps.ai_compress_a.outputs.response-file }}" /tmp/compress-a.txt sleep 65 @@ -523,7 +523,7 @@ jobs: prompt-file: /tmp/prompt-merge-b.txt - name: Save merge-B response - if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' + if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' && steps.ai_merge_b.conclusion == 'success' run: | cp "${{ steps.ai_merge_b.outputs.response-file }}" /tmp/merge-b.txt sleep 65 @@ -556,7 +556,7 @@ jobs: prompt-file: /tmp/prompt-compress-b.txt - name: Save compress-B response - if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' + if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' && steps.ai_compress_b.conclusion == 'success' run: | cp "${{ steps.ai_compress_b.outputs.response-file }}" /tmp/compress-b.txt sleep 65 @@ -641,7 +641,7 @@ jobs: prompt-file: /tmp/prompt-final.txt - name: Save final merge response - if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' + if: steps.tag_check.outputs.exists == 'false' && github.event.inputs.skip_ai_notes != 'true' && steps.ai_merge.conclusion == 'success' run: cp "${{ steps.ai_merge.outputs.response-file }}" /tmp/final-merge.txt - name: Write release notes to file if: steps.tag_check.outputs.exists == 'false' diff --git a/build/Package.props b/build/Package.props index d521d115..a1c7caa8 100644 --- a/build/Package.props +++ b/build/Package.props @@ -4,7 +4,7 @@ enable enable default - 5.1.3-alpha2 + 5.1.3-alpha2-fix Dmitry Zhutkov (Onebeld), Andrey Savich (pieckenst) Dmitry Zhutkov (Onebeld) theme, design, xaml, library, ui, gui, control, csharp, styled-components, interface, dotnet, nuget, style, avalonia, controls, user-interface, styles, avaloniaui, pleasant, graphical-user-interface diff --git a/src/PleasantUI/Controls/NavigationView/NavigationViewItem.cs b/src/PleasantUI/Controls/NavigationView/NavigationViewItem.cs index 19dfbcaa..ac1e1e25 100644 --- a/src/PleasantUI/Controls/NavigationView/NavigationViewItem.cs +++ b/src/PleasantUI/Controls/NavigationView/NavigationViewItem.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Metadata; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; @@ -28,6 +29,8 @@ public class NavigationViewItem : TreeViewItem private bool _isSubMenuOpen; private Popup? _popup; + private SmoothScrollViewer? _popupScrollViewer; + private NavigationViewSubMenuControl? _subMenuControl; /// /// Defines the property. @@ -78,7 +81,10 @@ public class NavigationViewItem : TreeViewItem /// Defines the property. /// public static readonly DirectProperty NavigationViewDistanceProperty = - AvaloniaProperty.RegisterDirect(nameof(NavigationViewDistance), o => o.Level); + AvaloniaProperty.RegisterDirect( + nameof(NavigationViewDistance), + o => o.NavigationViewDistance, + (o, v) => o.NavigationViewDistance = v); /// /// Defines the property. @@ -109,6 +115,13 @@ public class NavigationViewItem : TreeViewItem o => o.IsSubMenuOpen, (o, v) => o.IsSubMenuOpen = v); + /// + /// Defines the property. + /// Reference to the parent NavigationView for popup submenu items that aren't in the logical tree. + /// + public static readonly StyledProperty NavigationViewProperty = + AvaloniaProperty.Register(nameof(NavigationView)); + /// /// Defines the routed event for when the is opened. /// @@ -259,6 +272,16 @@ public bool IsSubMenuOpen set => SetAndRaise(IsSubMenuOpenProperty, ref _isSubMenuOpen, value); } + /// + /// Gets or sets the parent NavigationView. + /// Used by popup submenu items to navigate when not in the logical tree. + /// + public NavigationView? NavigationView + { + get => GetValue(NavigationViewProperty); + set => SetValue(NavigationViewProperty, value); + } + /// /// Occurs when the Opened event is raised. /// @@ -307,6 +330,7 @@ static NavigationViewItem() else navigationViewItem.OnDeselected(navigationViewItem, e); }); + IsSubMenuOpenProperty.Changed.Subscribe(new AnonymousObserver>(OnIsSubMenuOpenChanged)); IsOpenProperty.Changed.Subscribe(new AnonymousObserver>(OnIsOpenChanged)); OpenPaneLengthProperty.Changed.Subscribe(new AnonymousObserver>(OnPaneSizesChanged)); CompactPaneLengthProperty.Changed.Subscribe(new AnonymousObserver>(OnPaneSizesChanged)); @@ -384,6 +408,14 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) } _popup = e.NameScope.Find("PART_Popup"); + _popupScrollViewer = e.NameScope.Find("PART_PopupScrollViewer"); + _subMenuControl = e.NameScope.Find("PART_SubMenuControl"); + + // Wire up the submenu control to this parent item + if (_subMenuControl is not null) + { + _subMenuControl.NavigationViewItem = this; + } if (_popup is not null) { @@ -462,35 +494,98 @@ private static void OnIsOpenChanged(AvaloniaPropertyChangedEventArgs e) } } - private void OnPopupClosed(object? sender, EventArgs e) + private static void OnIsSubMenuOpenChanged(AvaloniaPropertyChangedEventArgs e) { - IsSubMenuOpen = false; - } + if (e.Sender is not NavigationViewItem item) return; - private void UpdatePseudoClasses() - { - if (IsOpen) + if (e.NewValue.GetValueOrDefault()) { - PseudoClasses.Remove(":closed"); - PseudoClasses.Add(":opened"); + // Popup opening: add the ItemsPresenter to show children + item.AddPopupItemsPresenter(); } else { - PseudoClasses.Remove(":opened"); - PseudoClasses.Add(":closed"); + // Popup closing: remove the ItemsPresenter to free containers + item.RemovePopupItemsPresenter(); } } - private void Select() + private void OnPopupClosed(object? sender, EventArgs e) + { + IsSubMenuOpen = false; + } + + private void AddPopupItemsPresenter() + { + if (_subMenuControl is null || _popupScrollViewer is null) return; + + // Set the ItemsSource to our logical children + _subMenuControl.ItemsSource = LogicalChildren; + + // Ensure it's attached to the scroll viewer + if (!ReferenceEquals(_popupScrollViewer.Content, _subMenuControl)) + _popupScrollViewer.Content = _subMenuControl; + } + + + private void RemovePopupItemsPresenter() { - // When pane is closed (compact mode) and item has children, toggle the popup submenu - if (!IsOpen && ItemCount > 0) + if (_subMenuControl is null) return; + + // Detach from popup scroll viewer and clear items + if (_popupScrollViewer?.Content == _subMenuControl) + _popupScrollViewer.Content = null; + + _subMenuControl.ItemsSource = null; +} + +private void UpdatePseudoClasses() +{ + if (IsOpen) + { + PseudoClasses.Remove(":closed"); + PseudoClasses.Add(":opened"); + } + else + { + PseudoClasses.Remove(":opened"); + PseudoClasses.Add(":closed"); + } +} + +private void Select() +{ + // Try to find NavigationView from logical tree first, fall back to stored property + var navigationView = this.GetParentTOfLogical() ?? NavigationView; + var isPaneOpen = navigationView?.IsOpen ?? IsOpen; + + // Check if this item has child navigation items + var hasChildren = this.LogicalChildren.OfType().Any(); + + // When pane is closed (compact mode) and item has children, toggle the popup submenu + // NOTE: use NavigationView.IsOpen instead of this item's IsOpen because items can override IsOpen locally. + if (!isPaneOpen && hasChildren) + { + IsSubMenuOpen = !IsSubMenuOpen; + return; + } + + // If this item is a popup clone (not in the NavigationView logical tree but has NavigationView set), + // find the original item in the tree by Tag and select that instead so SelectionChanged fires correctly. + bool isPopupClone = navigationView is not null && this.GetParentTOfLogical() is null; + if (isPopupClone && Tag is not null) + { + var original = navigationView!.GetLogicalDescendants() + .OfType() + .FirstOrDefault(x => Equals(x.Tag, Tag)); + if (original is not null) { - IsSubMenuOpen = !IsSubMenuOpen; + navigationView.SelectSingleItem(original); return; } - - if (!IsSelected) - this.GetParentTOfLogical()?.SelectSingleItem(this); } + + if (!IsSelected) + navigationView?.SelectSingleItem(this); +} } \ No newline at end of file diff --git a/src/PleasantUI/Controls/NavigationView/NavigationViewSubMenuControl.cs b/src/PleasantUI/Controls/NavigationView/NavigationViewSubMenuControl.cs new file mode 100644 index 00000000..e9e6e9c7 --- /dev/null +++ b/src/PleasantUI/Controls/NavigationView/NavigationViewSubMenuControl.cs @@ -0,0 +1,167 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Layout; +using Avalonia.LogicalTree; +using PleasantUI.Core.Extensions; + +namespace PleasantUI.Controls; + +/// +/// A control that hosts submenu items for NavigationViewItem's popup. +/// This is a separate control instance that creates its own NavigationViewItem containers, +/// avoiding visual parent conflicts with the inline ItemsPresenter. +/// +public class NavigationViewSubMenuControl : ItemsControl +{ + private ItemsPresenter? _itemsPresenter; + + /// + /// Defines the property. + /// + public static readonly StyledProperty NavigationViewItemProperty = + AvaloniaProperty.Register(nameof(NavigationViewItem)); + + /// + /// Gets or sets the parent NavigationViewItem that owns this submenu. + /// + public NavigationViewItem? NavigationViewItem + { + get => GetValue(NavigationViewItemProperty); + set => SetValue(NavigationViewItemProperty, value); + } + + static NavigationViewSubMenuControl() + { + // No ItemTemplate - we create NavigationViewItem containers directly + ItemTemplateProperty.OverrideDefaultValue(null); + } + + public NavigationViewSubMenuControl() + { + Classes.Add("navigationViewSubMenu"); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _itemsPresenter = e.NameScope.Find("PART_ItemsPresenter"); + } + + /// + protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + // Always create a fresh NavigationViewItem container + var container = new NavigationViewItem(); + return container; + } + + /// + protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + // Always create a new container, never reuse the item directly + recycleKey = null; + return true; + } + + /// + protected override void PrepareContainerForItemOverride(Control element, object? item, int index) + { + base.PrepareContainerForItemOverride(element, item, index); + + if (element is NavigationViewItem navItem && item is not null) + { + // The item is either: + // 1. A NavigationViewItem (from LogicalChildren) - we need to copy its properties + // 2. A data object - we need to set it as the container's data context + + if (item is NavigationViewItem sourceItem) + { + // Copy all relevant properties from the source NavigationViewItem + navItem.Header = sourceItem.Header; + navItem.HeaderTemplate = sourceItem.HeaderTemplate; + navItem.Icon = sourceItem.Icon; + navItem.Content = sourceItem.Content; + navItem.Title = sourceItem.Title; + navItem.Tag = sourceItem.Tag; + navItem.IsSelected = sourceItem.IsSelected; + navItem.SelectOnClose = sourceItem.SelectOnClose; + navItem.ClickMode = sourceItem.ClickMode; + + // Clone the children recursively - create NEW NavigationViewItem instances + // Do NOT add the original children as they already have visual parents + foreach (var child in sourceItem.Items.OfType()) + { + var clonedChild = CloneNavigationViewItem(child); + navItem.Items.Add(clonedChild); + } + } + else + { + // It's a data object, set it as the DataContext + navItem.DataContext = item; + navItem.Header = item; + } + + // Apply styling from parent NavigationViewItem and propagate NavigationView reference + var parent = NavigationViewItem; + if (parent is not null) + { + navItem.CompactPaneLength = parent.CompactPaneLength; + navItem.OpenPaneLength = parent.OpenPaneLength; + navItem.IsOpen = true; // Submenu items always show as open in popup + // Propagate the NavigationView reference so popup items can trigger navigation + navItem.NavigationView = parent.NavigationView ?? parent.GetParentTOfLogical(); + } + } + } + + /// + protected override void ClearContainerForItemOverride(Control element) + { + base.ClearContainerForItemOverride(element); + + if (element is NavigationViewItem navItem) + { + // Clear properties to prepare for recycling + navItem.Header = null; + navItem.Icon = null; + navItem.Content = null; + navItem.DataContext = null; + + // Reset distance + navItem.SetValue(NavigationViewItem.NavigationViewDistanceProperty, 0); + } + } + + /// + /// Recursively clones a NavigationViewItem and all its children. + /// Creates completely new instances to avoid visual parent conflicts. + /// + private static NavigationViewItem CloneNavigationViewItem(NavigationViewItem source) + { + var clone = new NavigationViewItem + { + Header = source.Header, + HeaderTemplate = source.HeaderTemplate, + Icon = source.Icon, + Content = source.Content, + Title = source.Title, + Tag = source.Tag, + IsSelected = source.IsSelected, + SelectOnClose = source.SelectOnClose, + ClickMode = source.ClickMode, + IsOpen = true // Submenu items always show as open in popup + }; + + // Recursively clone all children using public Items property + foreach (var child in source.Items.OfType()) + { + var clonedChild = CloneNavigationViewItem(child); + clone.Items.Add(clonedChild); + } + + return clone; + } +} diff --git a/src/PleasantUI/Styling/ControlThemes/PleasantControls/NavigationViewItem.axaml b/src/PleasantUI/Styling/ControlThemes/PleasantControls/NavigationViewItem.axaml index feb3aa52..2e0ecf66 100644 --- a/src/PleasantUI/Styling/ControlThemes/PleasantControls/NavigationViewItem.axaml +++ b/src/PleasantUI/Styling/ControlThemes/PleasantControls/NavigationViewItem.axaml @@ -161,12 +161,11 @@ MinWidth="160" MinHeight="32" CornerRadius="{StaticResource ControlCornerRadius}"> - - + @@ -375,4 +374,15 @@ + + + + + + + + + + \ No newline at end of file From 256ec182ba1779402c21cfdd66917c2832cbf3c5 Mon Sep 17 00:00:00 2001 From: Andrey Savich <46422808+pieckenst@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:26:35 +0300 Subject: [PATCH 3/4] fix(NavigationView): Correct selection state and inline items visibility - Fix HomeNavItem appearing highlighted when leaf pages are active by explicitly clearing its selection state - Add SyncInlineItemsPresenterVisibility method to manage inline children visibility based on IsOpen and IsExpanded states - Wire up inline items presenter visibility sync in OnApplyTemplate and whenever IsOpen or IsExpanded changes - Add ClipToBounds to PART_BottomBorder to prevent overflow of inline items - Simplify popup clone detection logic by removing intermediate variable - Ensure leaf item selection is restored after navigation redirect to home --- samples/PleasantUI.Example/MainView.axaml.cs | 4 ++++ .../NavigationView/NavigationViewItem.cs | 21 ++++++++++++++++--- .../PleasantControls/NavigationViewItem.axaml | 2 +- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/samples/PleasantUI.Example/MainView.axaml.cs b/samples/PleasantUI.Example/MainView.axaml.cs index 8e9e0ef1..551e4766 100644 --- a/samples/PleasantUI.Example/MainView.axaml.cs +++ b/samples/PleasantUI.Example/MainView.axaml.cs @@ -83,6 +83,10 @@ private void OnNavigationSelectionChanged(object? sender, SelectionChangedEventA // stays visible, without going through SelectionChanged again. MainNavigationView.SelectionChanged -= OnNavigationSelectionChanged; MainNavigationView.SelectedItem = HomeNavItem; + // HomeNavItem must not appear highlighted while a leaf page is active. + HomeNavItem.IsSelected = false; + // Re-highlight the leaf item — SelectSingleItemCore cleared it when we redirected to HomeNavItem. + selected.IsSelected = true; MainNavigationView.SelectionChanged += OnNavigationSelectionChanged; } diff --git a/src/PleasantUI/Controls/NavigationView/NavigationViewItem.cs b/src/PleasantUI/Controls/NavigationView/NavigationViewItem.cs index ac1e1e25..67e2966e 100644 --- a/src/PleasantUI/Controls/NavigationView/NavigationViewItem.cs +++ b/src/PleasantUI/Controls/NavigationView/NavigationViewItem.cs @@ -31,6 +31,7 @@ public class NavigationViewItem : TreeViewItem private Popup? _popup; private SmoothScrollViewer? _popupScrollViewer; private NavigationViewSubMenuControl? _subMenuControl; + private ItemsPresenter? _inlineItemsPresenter; /// /// Defines the property. @@ -319,6 +320,8 @@ static NavigationViewItem() RoutedEventArgs routedEventArgs = new(ClosedEvent); navigationViewItem.RaiseEvent(routedEventArgs); } + // Sync inline presenter visibility whenever IsExpanded changes + navigationViewItem.SyncInlineItemsPresenterVisibility(); }); OpenedEvent.AddClassHandler((x, e) => x.OnOpened(x, e)); ClosedEvent.AddClassHandler((x, e) => x.OnClosed(x, e)); @@ -410,6 +413,7 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) _popup = e.NameScope.Find("PART_Popup"); _popupScrollViewer = e.NameScope.Find("PART_PopupScrollViewer"); _subMenuControl = e.NameScope.Find("PART_SubMenuControl"); + _inlineItemsPresenter = e.NameScope.Find("PART_ItemsPresenter"); // Wire up the submenu control to this parent item if (_subMenuControl is not null) @@ -422,6 +426,8 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) _popup.Closed += OnPopupClosed; } + // Sync inline items presenter visibility with current state + SyncInlineItemsPresenterVisibility(); UpdatePseudoClasses(); } @@ -492,6 +498,9 @@ private static void OnIsOpenChanged(AvaloniaPropertyChangedEventArgs e) sender.RaiseEvent(new RoutedEventArgs(ClosedEvent)); break; } + + // Always sync inline presenter visibility when IsOpen changes + sender.SyncInlineItemsPresenterVisibility(); } private static void OnIsSubMenuOpenChanged(AvaloniaPropertyChangedEventArgs e) @@ -539,6 +548,13 @@ private void RemovePopupItemsPresenter() _subMenuControl.ItemsSource = null; } +private void SyncInlineItemsPresenterVisibility() +{ + if (_inlineItemsPresenter is null) return; + // Show inline children only when pane is open AND this item is expanded + _inlineItemsPresenter.IsVisible = IsOpen && IsExpanded; +} + private void UpdatePseudoClasses() { if (IsOpen) @@ -572,10 +588,9 @@ private void Select() // If this item is a popup clone (not in the NavigationView logical tree but has NavigationView set), // find the original item in the tree by Tag and select that instead so SelectionChanged fires correctly. - bool isPopupClone = navigationView is not null && this.GetParentTOfLogical() is null; - if (isPopupClone && Tag is not null) + if (navigationView is not null && this.GetParentTOfLogical() is null && Tag is not null) { - var original = navigationView!.GetLogicalDescendants() + var original = navigationView.GetLogicalDescendants() .OfType() .FirstOrDefault(x => Equals(x.Tag, Tag)); if (original is not null) diff --git a/src/PleasantUI/Styling/ControlThemes/PleasantControls/NavigationViewItem.axaml b/src/PleasantUI/Styling/ControlThemes/PleasantControls/NavigationViewItem.axaml index 2e0ecf66..33e3743f 100644 --- a/src/PleasantUI/Styling/ControlThemes/PleasantControls/NavigationViewItem.axaml +++ b/src/PleasantUI/Styling/ControlThemes/PleasantControls/NavigationViewItem.axaml @@ -119,7 +119,7 @@ - + From 95c3bfc53c68e95131002459c53c9037ea4cb855 Mon Sep 17 00:00:00 2001 From: Andrey Savich <46422808+pieckenst@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:39:20 +0300 Subject: [PATCH 4/4] feat(NavigationView): Add expanded state preservation and improve pane collapse handling - Add Dictionary to track IsExpanded state of group items before pane collapse - Implement OnIsOpenChanged handler to preserve and restore item expansion states - Add comprehensive Debug.WriteLine statements throughout lifecycle for troubleshooting - Remove automatic item collapse when switching to CompactOverlay and Overlay modes - Add zero-width bounds check in OnBoundsChanged to prevent corrupting saved state during initial layout - Improve code formatting and add explanatory comments for state management logic - Prevents loss of user's expanded/collapsed preferences when toggling pane visibility --- .../Controls/NavigationView/NavigationView.cs | 92 +++++++++++++++--- .../NavigationView/NavigationViewItem.cs | 97 ++++++++++++------- .../PleasantControls/NavigationViewItem.axaml | 7 ++ 3 files changed, 146 insertions(+), 50 deletions(-) diff --git a/src/PleasantUI/Controls/NavigationView/NavigationView.cs b/src/PleasantUI/Controls/NavigationView/NavigationView.cs index f533188d..1a606f06 100644 --- a/src/PleasantUI/Controls/NavigationView/NavigationView.cs +++ b/src/PleasantUI/Controls/NavigationView/NavigationView.cs @@ -1,4 +1,5 @@ -using System.Runtime.InteropServices; +using System.Diagnostics; +using System.Runtime.InteropServices; using System.Windows.Input; using Avalonia; using Avalonia.Animation; @@ -52,6 +53,10 @@ public class NavigationView : TreeView private ILogical? _logicalSelectedContent; + // Stores the IsExpanded state of group items before the pane collapses to compact mode, + // keyed by the NavigationViewItem instance so each item's state is tracked independently. + private readonly Dictionary _expandedStates = new(); + /// /// Defines the property. /// @@ -380,6 +385,7 @@ static NavigationView() { SelectionModeProperty.OverrideDefaultValue(SelectionMode.Single); SelectedItemProperty.Changed.AddClassHandler((x, _) => x.OnSelectedItemChanged()); + IsOpenProperty.Changed.AddClassHandler((x, e) => x.OnIsOpenChanged(e)); } /// @@ -397,18 +403,20 @@ public NavigationView() /// protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { + Debug.WriteLine("[NavigationView] OnApplyTemplate"); base.OnApplyTemplate(e); _stackPanelButtons = e.NameScope.Find("PART_StackPanelButtons"); _headerItem = e.NameScope.Find