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