From b20448fcd6b8464fb3d06fa2c2474afeeb77f365 Mon Sep 17 00:00:00 2001 From: Joshua Rowe Date: Wed, 7 Oct 2026 17:28:37 -0700 Subject: [PATCH 01/36] Fix #1618: switch entity Views to nonvirtualized panel below 100 nodes Preserve offset, focus, expansion and selection across live threshold changes while leaving unrelated trees virtualized. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Styles/SharedStyles.axaml | 13 +- .../EntityTreePanelTests.cs | 193 ++++++++++++++++++ .../Controls/EntityTreePanel.cs | 94 +++++++++ Phantom.Workspaces/MainWindow.axaml | 2 +- .../EntityBrowserWorkspaceTabView.axaml | 3 +- 5 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 Phantom.Workspaces.Tests/EntityTreePanelTests.cs create mode 100644 Phantom.Workspaces/Controls/EntityTreePanel.cs diff --git a/Phantom.Workspaces.Gui.Shared/Styles/SharedStyles.axaml b/Phantom.Workspaces.Gui.Shared/Styles/SharedStyles.axaml index 157800ef..100b470b 100644 --- a/Phantom.Workspaces.Gui.Shared/Styles/SharedStyles.axaml +++ b/Phantom.Workspaces.Gui.Shared/Styles/SharedStyles.axaml @@ -327,6 +327,18 @@ + + @@ -286,4 +287,3 @@ - diff --git a/Phantom.Workspaces/Templates/EntityBrowserWorkspaceTabView.axaml b/Phantom.Workspaces/Templates/EntityBrowserWorkspaceTabView.axaml index 892b860b..76b3c278 100644 --- a/Phantom.Workspaces/Templates/EntityBrowserWorkspaceTabView.axaml +++ b/Phantom.Workspaces/Templates/EntityBrowserWorkspaceTabView.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:Phantom.Workspaces.ViewModels" xmlns:controls="using:Phantom.Workspaces.Gui.Shared.Controls" + xmlns:appControls="using:Phantom.Workspaces.Controls" x:Class="Phantom.Workspaces.Templates.EntityBrowserWorkspaceTabView" x:DataType="vm:EntityBrowserWorkspaceTabViewModel"> @@ -36,7 +37,7 @@ - From 50ba65505e2a6cff32e11e3a8d3eaa27688c22f1 Mon Sep 17 00:00:00 2001 From: Joshua Rowe Date: Wed, 7 Oct 2026 18:13:46 -0700 Subject: [PATCH 02/36] Fix #1618: retain visible tree anchors and focused card controls Test actual browser and primary View count bindings and notify the browser count explicitly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...EntityBrowserWorkspaceTabViewModelTests.cs | 79 ++++++++++++++++++ .../EntityTreePanelTests.cs | 83 +++++++++++++++++++ .../MainWindowIntegrationTests.cs | 68 +++++++++++++++ .../Controls/EntityTreePanel.cs | 62 ++++++++++++-- .../EntityBrowserWorkspaceTabView.axaml | 2 +- .../ViewModels/EntityListViewModel.cs | 7 ++ 6 files changed, 292 insertions(+), 9 deletions(-) diff --git a/Phantom.Workspaces.Tests/EntityBrowserWorkspaceTabViewModelTests.cs b/Phantom.Workspaces.Tests/EntityBrowserWorkspaceTabViewModelTests.cs index f5dfef0c..d6b740b9 100644 --- a/Phantom.Workspaces.Tests/EntityBrowserWorkspaceTabViewModelTests.cs +++ b/Phantom.Workspaces.Tests/EntityBrowserWorkspaceTabViewModelTests.cs @@ -1,4 +1,9 @@ using Avalonia.Headless.XUnit; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Phantom.Workspaces.Templates; using System.Collections.Specialized; using System.Text.Json; using System.Threading; @@ -11,6 +16,80 @@ namespace Phantom.Workspaces.Tests; public sealed class EntityBrowserWorkspaceTabViewModelTests { + [AvaloniaFact(Timeout = 30_000)] + public async Task BrowserTemplate_ActualItemsCountBinding_SwitchesStyledPanelAtThreshold() + { + var broker = await CreateBrokerAsync(); + var subscription = await broker.SubscribeGetAsync(new GetRequest + { + Entities = [new GetEntityRequest + { + EntityName = EntityName.Root, + EnumerateChildren = EnumerateChildrenAction.EnumerateSelf, + }], + Timestamps = [null], + }, TestContext.Current.CancellationToken); + var model = new EntityBrowserWorkspaceTabViewModel(broker, subscription) + { + Id = "browser-panel-test", + Title = "Browser", + }; + // Freeze the broker-driven rebuild; this test mutates the real bound list deterministically. + await model.DisposeAsync(); + var view = new EntityBrowserWorkspaceTabView { DataContext = model }; + var tree = Assert.IsType(view.FindControl("BrowserTreeView")); + tree.ItemTemplate = new FuncDataTemplate( + (item, _) => new TextBlock { Text = item.ItemKey, Height = 25 }); + var window = new Window { Content = view, Width = 400, Height = 320 }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + Assert.Same(tree, Assert.Single(view.GetVisualDescendants().OfType())); + model.EntityList.Items.Clear(); + Assert.IsType(tree.ItemsPanelRoot); + var first = new EntityListNodeViewModel("One", "entity", ["one"], "one"); + model.EntityList.Items.Add(new EntityListItemViewModel(first, 0, 0, "one")); + Dispatcher.UIThread.RunJobs(); + Assert.IsType(tree.ItemsPanelRoot); + model.EntityList.Items.Clear(); + window.Content = null; + for (int i = 0; i < 100; i++) + { + var node = new EntityListNodeViewModel($"Node {i}", "entity", [$"node-{i}"], $"node-{i}"); + model.EntityList.Items.Add(new EntityListItemViewModel(node, i, 0, $"node-{i}")); + } + window.Content = view; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(100, model.EntityList.Items.Count); + Assert.Equal(100, model.EntityList.NodeCount); + Assert.Equal(100, Phantom.Workspaces.Controls.EntityTreePanel.GetNodeCount(tree)); + Assert.IsType(tree.ItemsPanelRoot); + model.EntityList.Items.RemoveAt(99); + Dispatcher.UIThread.RunJobs(); + Assert.IsType(tree.ItemsPanelRoot); + model.EntityList.ApplyFindFilter([], hideUnmatched: true); + Dispatcher.UIThread.RunJobs(); + Assert.IsType(tree.ItemsPanelRoot); + + window.Content = null; + for (int i = 99; i < 1000; i++) + { + var node = new EntityListNodeViewModel($"Node {i}", "entity", [$"node-{i}"], $"node-{i}"); + model.EntityList.Items.Add(new EntityListItemViewModel(node, i, 0, $"node-{i}")); + } + window.Content = view; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(1000, Phantom.Workspaces.Controls.EntityTreePanel.GetNodeCount(tree)); + Assert.IsType(tree.ItemsPanelRoot); + Assert.True(tree.ItemsPanelRoot!.Children.Count < 500); + } + finally + { + window.Close(); + } + } + [AvaloniaFact] public async Task BrowserList_TracksParentChildMetadataAndExpansion() { diff --git a/Phantom.Workspaces.Tests/EntityTreePanelTests.cs b/Phantom.Workspaces.Tests/EntityTreePanelTests.cs index 7eb6bac4..9bdd3ef9 100644 --- a/Phantom.Workspaces.Tests/EntityTreePanelTests.cs +++ b/Phantom.Workspaces.Tests/EntityTreePanelTests.cs @@ -4,6 +4,7 @@ using Avalonia.Data; using Avalonia.Headless.XUnit; using Avalonia.VisualTree; +using Avalonia.Input; using Phantom.Workspaces.Controls; using Phantom.Workspaces.Testing.Gui; using Phantom.Workspaces.ViewModels; @@ -178,6 +179,87 @@ public void EntityCardTreeView_WhenPopulationCrossesThreshold_PreservesSelection } } + [AvaloniaFact] + public void EntityCardTreeView_InsertAndRemoveBeforeViewport_PreserveVisibleObjectAtVariableHeights() + { + var nodes = new ObservableCollection( + Enumerable.Range(0, 99).Select(i => new TreeNode($"Node {i}"))); + var tree = CreateTree(nodes); + tree.ItemTemplate = new Avalonia.Controls.Templates.FuncTreeDataTemplate( + (node, _) => new TextBlock { Text = node.Name, Height = node.Height }, + node => node.Children); + tree.Bind(EntityTreePanel.NodeCountProperty, new Binding("Count") { Source = nodes }); + var window = new Window { Content = tree, Width = 400, Height = 240 }; + try + { + window.Show(); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + var scroll = Assert.Single(tree.GetVisualDescendants().OfType()); + scroll.Offset = new Vector(0, 410); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + var anchor = Assert.IsType(tree.ContainerFromItem(nodes[7])); + var before = Assert.NotNull(anchor.TranslatePoint(default, scroll)).Y; + + var inserted = new TreeNode("Tall item") { Height = 91 }; + nodes.Insert(3, inserted); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + Assert.IsType(tree.ItemsPanelRoot); + Assert.Equal(before, Assert.NotNull( + Assert.IsType(tree.ContainerFromItem(anchor.DataContext!)) + .TranslatePoint(default, scroll)).Y, 1); + + nodes.Remove(inserted); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + Assert.IsType(tree.ItemsPanelRoot); + Assert.Equal(before, Assert.NotNull( + Assert.IsType(tree.ContainerFromItem(anchor.DataContext!)) + .TranslatePoint(default, scroll)).Y, 1); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void EntityCardTreeView_NestedEditorFocus_SurvivesBothPanelTransitions() + { + var nodes = new ObservableCollection( + Enumerable.Range(0, 99).Select(i => new TreeNode($"Node {i}"))); + var child = new TreeNode("Nested"); + nodes[0].Children.Add(child); + var tree = CreateTree(nodes); + tree.ItemTemplate = new Avalonia.Controls.Templates.FuncTreeDataTemplate( + (node, _) => new Button { Name = "Editor", Content = node.Name, Height = 34 }, + node => node.Children); + tree.Bind(EntityTreePanel.NodeCountProperty, new Binding("Count") { Source = nodes }); + var window = new Window { Content = tree, Width = 400, Height = 240 }; + try + { + window.Show(); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + Assert.IsType(tree.ContainerFromIndex(0)).IsExpanded = true; + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + var editor = tree.GetVisualDescendants().OfType