From b8039fa44ee8f84a928f4b45bf28b3fcfd264cb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 01:05:08 +0000 Subject: [PATCH] fix: invalidate each ancestor once instead of 2^depth times [patch] UIElementBase.Invalidate() called Parent.Invalidate() and also raised Invalidated, which every container subscribes to for its children, so each level passed the invalidation up twice and the root saw 2^depth calls. Keep the event subscription as the single propagation path. Fixes ktsu-dev/TUI#148 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Jn27sjy5Z1Sbi1aZaAJDgj --- TUI.Test/UIContainerBaseTests.cs | 50 ++++++++++++++++++++++++++++++++ TUI/Contracts/IUIElement.cs | 4 +-- TUI/Elements/UIElementBase.cs | 6 +++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/TUI.Test/UIContainerBaseTests.cs b/TUI.Test/UIContainerBaseTests.cs index b398b24..e75f856 100644 --- a/TUI.Test/UIContainerBaseTests.cs +++ b/TUI.Test/UIContainerBaseTests.cs @@ -67,4 +67,54 @@ public void AMovedChildNoLongerInvalidatesItsPreviousContainer() Assert.AreEqual(0, firstInvalidations); } + + /// + /// A change deep in the tree reaches the root once, not once per route at every level + /// (ktsu-dev/TUI#148) + /// + [TestMethod] + public void AChangeNestedManyLevelsDeepInvalidatesTheRootOnce() + { + StackPanel root = []; + StackPanel current = root; + for (int i = 0; i < 15; i++) + { + StackPanel next = []; + current.AddChild(next); + current = next; + } + + TextElement leaf = new() { Text = "x" }; + current.AddChild(leaf); + + int count = 0; + root.Invalidated += (_, _) => count++; + + leaf.Text = "y"; + + Assert.AreEqual(1, count); + } + + /// + /// Each ancestor between the change and the root is invalidated exactly once + /// + [TestMethod] + public void AChangeInvalidatesEachAncestorOnce() + { + StackPanel root = []; + StackPanel middle = []; + TextElement leaf = new() { Text = "x" }; + root.AddChild(middle); + middle.AddChild(leaf); + + int rootCount = 0; + int middleCount = 0; + root.Invalidated += (_, _) => rootCount++; + middle.Invalidated += (_, _) => middleCount++; + + leaf.Text = "y"; + + Assert.AreEqual(1, middleCount); + Assert.AreEqual(1, rootCount); + } } diff --git a/TUI/Contracts/IUIElement.cs b/TUI/Contracts/IUIElement.cs index 5d257c2..cceda6d 100644 --- a/TUI/Contracts/IUIElement.cs +++ b/TUI/Contracts/IUIElement.cs @@ -67,8 +67,8 @@ public interface IUIElement public Dimensions CalculateRequiredDimensions(int availableWidth) => CalculateRequiredDimensions(); /// - /// Marks the element as changed since its last draw, notifies its parent, and raises the - /// invalidated event + /// Marks the element as changed since its last draw and raises the invalidated event, which is + /// how the containing parent hears about the change /// /// /// Rendering redraws every visible element on every pass, so this does not decide whether an diff --git a/TUI/Elements/UIElementBase.cs b/TUI/Elements/UIElementBase.cs index 96108aa..f281a7c 100644 --- a/TUI/Elements/UIElementBase.cs +++ b/TUI/Elements/UIElementBase.cs @@ -103,10 +103,14 @@ public virtual void Render(IConsoleProvider provider) public virtual Dimensions CalculateRequiredDimensions(int availableWidth) => OnCalculateRequiredDimensions(availableWidth); /// + /// + /// The parent hears about this through , which every container + /// subscribes to when it adopts a child. Calling the parent directly as well would reach it by + /// two routes, doubling the count at every level (ktsu-dev/TUI#148). + /// public void Invalidate() { IsDirty = true; - Parent?.Invalidate(); Invalidated?.Invoke(this, EventArgs.Empty); }