Skip to content

Commit ff7e41b

Browse files
author
Nikolaos Protopapas
committed
perf(portals): replace brute-force repaint with targeted delta restoration
Remove EnsureWindowsFreshUnderPortals which forced full desktop fill and all-window invalidation every frame while any portal was open. Replace with delta-based region restoration that only repaints screen areas where portal control bounds actually changed. - Add PreviousControlBounds tracking on DesktopPortal - Add RestoreScreenRegion for selective desktop/window/portal re-blit - Add RestorePortalDelta for submenu open/close transitions - Add RestorePortalRegions called on portal removal - Remove NeedsCleanupFrame mechanism (restoration is now immediate) - Fix orphaned project GUID in solution file - Add tests for delta tracking, immediate restoration, no force-invalidation
1 parent 6f8c627 commit ff7e41b

7 files changed

Lines changed: 275 additions & 66 deletions

File tree

Examples/DemoApp/Program.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using SharpConsoleUI;
2+
using SharpConsoleUI.Configuration;
23
using SharpConsoleUI.Core;
34
using SharpConsoleUI.Drivers;
45
using SharpConsoleUI.Helpers;
@@ -14,7 +15,10 @@ static async Task<int> Main(string[] args)
1415

1516
try
1617
{
17-
var windowSystem = new ConsoleWindowSystem(new NetConsoleDriver(RenderMode.Buffer));
18+
var options = new ConsoleWindowSystemOptions(
19+
StatusBarOptions: new StatusBarOptions(ShowStartButton: true)
20+
);
21+
var windowSystem = new ConsoleWindowSystem(new NetConsoleDriver(RenderMode.Buffer), options: options);
1822
using var disposables = new DisposableManager();
1923

2024
windowSystem.StatusBarStateService.TopStatus = "SharpConsoleUI Demo | Ctrl+T: Theme Selector";

SharpConsoleUI.Tests/Rendering/DesktopPortalTests.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,80 @@ public void RenderDesktopPortals_CollectsControlBoundsAfterRender()
202202
Assert.True(firstBound.Height > 0, $"Control bound height should be > 0, got {firstBound.Height}");
203203
}
204204

205+
[Fact]
206+
public void RenderDesktopPortals_TracksPreviousControlBounds()
207+
{
208+
var system = TestWindowSystemBuilder.CreateTestSystem(80, 24);
209+
var content = new MarkupControl(new List<string> { "Hello" });
210+
211+
var portal = system.DesktopPortalService.CreatePortal(new DesktopPortalOptions(
212+
Content: content,
213+
Bounds: new Rectangle(0, 0, 40, 10)));
214+
215+
// First render — PreviousControlBounds starts empty
216+
Assert.Empty(portal.PreviousControlBounds);
217+
218+
system.Render.UpdateDisplay();
219+
220+
// After first render, ControlBounds should be populated
221+
Assert.NotEmpty(portal.ControlBounds);
222+
223+
// Force a second render by marking dirty
224+
portal.IsDirty = true;
225+
system.Render.UpdateDisplay();
226+
227+
// PreviousControlBounds should now contain the bounds from the first render
228+
Assert.NotEmpty(portal.PreviousControlBounds);
229+
}
230+
231+
[Fact]
232+
public void RemovePortal_RestoresScreenWithoutCleanupFrame()
233+
{
234+
var system = TestWindowSystemBuilder.CreateTestSystem(80, 24);
235+
var content = new MarkupControl(new List<string> { "Hello" });
236+
237+
var portal = system.DesktopPortalService.CreatePortal(new DesktopPortalOptions(
238+
Content: content,
239+
Bounds: new Rectangle(5, 5, 30, 10)));
240+
241+
// Render so portal has a buffer and control bounds
242+
system.Render.UpdateDisplay();
243+
Assert.NotNull(portal.Buffer);
244+
Assert.NotEmpty(portal.ControlBounds);
245+
246+
// Remove portal — should restore regions immediately
247+
system.DesktopPortalService.RemovePortal(portal);
248+
249+
Assert.False(system.DesktopPortalService.HasPortals);
250+
// DesktopNeedsRender should be set for the next frame
251+
Assert.True(system.Render.DesktopNeedsRender);
252+
}
253+
254+
[Fact]
255+
public void RenderWithPortalOpen_DoesNotForceAllWindowsDirty()
256+
{
257+
var system = TestWindowSystemBuilder.CreateTestSystem(80, 24);
258+
259+
// Create a window and render it
260+
var window = new Window(system) { Left = 0, Top = 0, Width = 40, Height = 12 };
261+
window.AddControl(new MarkupControl(new List<string> { "Window content" }));
262+
system.WindowStateService.AddWindow(window);
263+
264+
system.Render.UpdateDisplay();
265+
Assert.False(window.IsDirty); // Window should be clean after render
266+
267+
// Open a portal
268+
system.DesktopPortalService.CreatePortal(new DesktopPortalOptions(
269+
Content: new MarkupControl(new List<string> { "Portal" }),
270+
Bounds: new Rectangle(50, 5, 20, 5)));
271+
272+
// Render — portal is dirty, but window should NOT be force-dirtied
273+
system.Render.UpdateDisplay();
274+
275+
// Window should still be clean (not force-invalidated)
276+
Assert.False(window.IsDirty);
277+
}
278+
205279
#endregion
206280

207281
#region Hit Testing

SharpConsoleUI.sln

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -77,18 +77,7 @@ Global
7777
{BC01AF69-9657-4CAA-819E-979B7E1FC831}.Release|x64.Build.0 = Release|Any CPU
7878
{BC01AF69-9657-4CAA-819E-979B7E1FC831}.Release|x86.ActiveCfg = Release|Any CPU
7979
{BC01AF69-9657-4CAA-819E-979B7E1FC831}.Release|x86.Build.0 = Release|Any CPU
80-
{877F0629-B17F-46DE-975E-7D9486B89D9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
81-
{877F0629-B17F-46DE-975E-7D9486B89D9D}.Debug|Any CPU.Build.0 = Debug|Any CPU
82-
{877F0629-B17F-46DE-975E-7D9486B89D9D}.Debug|x64.ActiveCfg = Debug|Any CPU
83-
{877F0629-B17F-46DE-975E-7D9486B89D9D}.Debug|x64.Build.0 = Debug|Any CPU
84-
{877F0629-B17F-46DE-975E-7D9486B89D9D}.Debug|x86.ActiveCfg = Debug|Any CPU
85-
{877F0629-B17F-46DE-975E-7D9486B89D9D}.Debug|x86.Build.0 = Debug|Any CPU
86-
{877F0629-B17F-46DE-975E-7D9486B89D9D}.Release|Any CPU.ActiveCfg = Release|Any CPU
87-
{877F0629-B17F-46DE-975E-7D9486B89D9D}.Release|Any CPU.Build.0 = Release|Any CPU
88-
{877F0629-B17F-46DE-975E-7D9486B89D9D}.Release|x64.ActiveCfg = Release|Any CPU
89-
{877F0629-B17F-46DE-975E-7D9486B89D9D}.Release|x64.Build.0 = Release|Any CPU
90-
{877F0629-B17F-46DE-975E-7D9486B89D9D}.Release|x86.ActiveCfg = Release|Any CPU
91-
{877F0629-B17F-46DE-975E-7D9486B89D9D}.Release|x86.Build.0 = Release|Any CPU
80+
9281
{FCAB68F0-B3B4-43BB-9506-145FE0D02736}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
9382
{FCAB68F0-B3B4-43BB-9506-145FE0D02736}.Debug|Any CPU.Build.0 = Debug|Any CPU
9483
{FCAB68F0-B3B4-43BB-9506-145FE0D02736}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -299,7 +288,7 @@ Global
299288
EndGlobalSection
300289
GlobalSection(NestedProjects) = preSolution
301290
{BC01AF69-9657-4CAA-819E-979B7E1FC831} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}
302-
{877F0629-B17F-46DE-975E-7D9486B89D9D} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}
291+
303292
{FCAB68F0-B3B4-43BB-9506-145FE0D02736} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}
304293
{5A0FDF41-ECDA-45C3-B738-AE2822F8FE0B} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}
305294
{9272729B-62ED-417E-910B-1BA3B1366A9A} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}

SharpConsoleUI/ConsoleWindowSystem.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ public int Run()
612612
}
613613

614614
// Frame pacing: render if windows are dirty OR metrics need update OR desktop needs render OR animations active
615-
bool shouldRender = AnyWindowDirty() || metricsNeedUpdate || Render.DesktopNeedsRender || Animations.HasActiveAnimations || Render.IsStatusBarDirty() || _desktopPortalService.AnyPortalDirty() || _desktopPortalService.NeedsCleanupFrame;
615+
bool shouldRender = AnyWindowDirty() || metricsNeedUpdate || Render.DesktopNeedsRender || Animations.HasActiveAnimations || Render.IsStatusBarDirty() || _desktopPortalService.AnyPortalDirty();
616616

617617
// Calculate recommended sleep duration once (used in both branches)
618618
var recommendedSleep = _inputStateService.GetRecommendedSleepDuration(

SharpConsoleUI/Core/DesktopPortal.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ public class DesktopPortal
9090
/// <summary>Cached control bounds from last render (used for selective rendering and hit testing).</summary>
9191
internal List<LayoutRect> ControlBounds { get; set; } = new();
9292

93+
/// <summary>Control bounds from the previous render — used to compute delta regions for restoration.</summary>
94+
internal List<LayoutRect> PreviousControlBounds { get; set; } = new();
95+
9396
/// <summary>The container implementation that connects Content to this portal.</summary>
9497
internal DesktopPortalContainer Container { get; }
9598

SharpConsoleUI/Core/DesktopPortalService.cs

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ public class DesktopPortalService
2323
private readonly List<DesktopPortal> _portals = new();
2424
private readonly Stack<Window?> _savedActiveWindows = new();
2525
private int _nextZOrder;
26-
private bool _needsCleanupFrame;
2726

2827
/// <summary>
2928
/// Initializes a new instance of the DesktopPortalService class.
@@ -43,16 +42,6 @@ public DesktopPortalService(ILogService logService, ConsoleWindowSystem windowSy
4342
/// </summary>
4443
public bool HasPortals => _portals.Count > 0;
4544

46-
/// <summary>
47-
/// Gets whether a cleanup frame is needed (portals were just removed).
48-
/// Cleared after the cleanup frame runs.
49-
/// </summary>
50-
public bool NeedsCleanupFrame
51-
{
52-
get => _needsCleanupFrame;
53-
internal set => _needsCleanupFrame = value;
54-
}
55-
5645
/// <summary>
5746
/// Gets the topmost portal (highest ZOrder), or null if none.
5847
/// </summary>
@@ -103,11 +92,8 @@ public void RemovePortal(DesktopPortal portal)
10392
// Disconnect content from invalidation chain
10493
portal.Content.Container = null;
10594

106-
// Invalidate all windows — next frame repaints everything fresh
107-
foreach (var window in _windowSystem.Windows.Values)
108-
{
109-
window.IsDirty = true;
110-
}
95+
// Restore screen regions that were covered by this portal
96+
_windowSystem.Render.RestorePortalRegions(portal);
11197

11298
// Restore focus
11399
if (_savedActiveWindows.Count > 0)
@@ -121,7 +107,6 @@ public void RemovePortal(DesktopPortal portal)
121107
}
122108
}
123109

124-
_needsCleanupFrame = true;
125110
_windowSystem.Render.DesktopNeedsRender = true;
126111
}
127112

@@ -142,20 +127,16 @@ public void DismissAllPortals()
142127
originalWindow = _savedActiveWindows.Pop();
143128
}
144129

145-
// Remove all portals
130+
// Capture portals before clearing
146131
var portalsCopy = _portals.ToList();
147132
_portals.Clear();
148133

134+
// Restore screen regions and clean up each portal
149135
foreach (var portal in portalsCopy)
150136
{
151137
portal.OnDismiss?.Invoke();
152138
portal.Content.Container = null;
153-
}
154-
155-
// Invalidate all windows — next frame repaints everything fresh
156-
foreach (var window in _windowSystem.Windows.Values)
157-
{
158-
window.IsDirty = true;
139+
_windowSystem.Render.RestorePortalRegions(portal);
159140
}
160141

161142
// Restore original active window
@@ -164,7 +145,6 @@ public void DismissAllPortals()
164145
_windowSystem.SetActiveWindow(originalWindow);
165146
}
166147

167-
_needsCleanupFrame = true;
168148
_windowSystem.Render.DesktopNeedsRender = true;
169149
}
170150

0 commit comments

Comments
 (0)