Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ Provider Abstraction (SpectreConsoleProvider)
**Rendering flow:**
1. `UIApplication` manages the main loop and input processing
2. Elements are arranged via `ArrangeChildren()` which sets child `Position` and `Dimensions`
3. `Invalidate()` marks elements dirty; only dirty elements re-render
3. Each pass is a full clear followed by a full redraw: `UIApplication.Render()` clears the
console and every visible element draws again. `Invalidate()` marks an element as changed and
raises `Invalidated`, but it does not gate drawing — a full clear combined with a dirty-only
redraw erases static elements rather than preserving them (ktsu-dev/TUI#109)
4. `UIContainerBase.Render()` renders itself then all visible children

## Code Patterns
Expand Down
8 changes: 7 additions & 1 deletion TUI.Core/Contracts/IUIElement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@ public interface IUIElement
public Dimensions CalculateRequiredDimensions();

/// <summary>
/// Invalidates the element, marking it for re-rendering
/// Marks the element as changed since its last draw, notifies its parent, and raises the
/// invalidated event
/// </summary>
/// <remarks>
/// Rendering redraws every visible element on every pass, so this does not decide whether an
/// element draws. It is the signal a host can use to know that something changed and that a
/// render pass is worth running.
/// </remarks>
public void Invalidate();
}
20 changes: 14 additions & 6 deletions TUI.Core/Elements/UIElementBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,13 @@ public bool IsVisible
public IUIContainer? Parent { get; set; }

/// <summary>
/// Gets whether the element needs to be re-rendered
/// Gets whether the element has changed since it was last drawn
/// </summary>
/// <remarks>
/// This reports pending changes; it does not gate drawing. The library renders with a full
/// clear followed by a full redraw (see <see cref="Render"/>), so every visible element draws
/// on every pass regardless of this flag.
/// </remarks>
protected bool IsDirty { get; private set; } = true;

/// <summary>
Expand All @@ -71,18 +76,21 @@ public bool IsVisible
public event EventHandler? Invalidated;

/// <inheritdoc />
/// <remarks>
/// Drawing is unconditional for a visible element. <see cref="ktsu.TUI.Core.Services.UIApplication.Render"/> clears
/// the whole console on every pass, so skipping a clean element would not leave its previous
/// output on screen — it would erase it. Gating on <see cref="IsDirty"/> here is what made
/// static elements vanish on the frame after their first draw (ktsu-dev/TUI#109).
/// </remarks>
public virtual void Render(IConsoleProvider provider)
{
if (!IsVisible)
{
return;
}

if (IsDirty)
{
OnRender(provider);
IsDirty = false;
}
OnRender(provider);
IsDirty = false;
}

/// <inheritdoc />
Expand Down
8 changes: 7 additions & 1 deletion TUI.Core/Services/UIApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
}

IsRunning = true;
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

Check warning on line 92 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Dispose '_cancellationTokenSource' when it is no longer needed.

Check warning on line 92 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Dispose '_cancellationTokenSource' when it is no longer needed.

try
{
Expand Down Expand Up @@ -145,6 +145,11 @@
}

/// <inheritdoc />
/// <remarks>
/// Each pass clears the console and redraws every visible element. Dirty tracking is not used
/// to skip elements — combining a full clear with a dirty-only redraw is what made static
/// elements disappear on the frame after their first draw (ktsu-dev/TUI#109).
/// </remarks>
public void Render()
{
if (RootElement == null)
Expand All @@ -163,7 +168,8 @@
LogRenderingUI(_logger, null);
}

// Clear the console
// Clear the console, then redraw the whole tree below. The two halves belong
// together: a clear without a full redraw erases whatever the last pass drew.
ConsoleProvider.Clear();

// Set root element dimensions to console dimensions if not set
Expand Down Expand Up @@ -199,7 +205,7 @@
}

/// <inheritdoc />
public async Task ProcessInputAsync(CancellationToken cancellationToken = default)

Check warning on line 208 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 32 to the 15 allowed.

Check warning on line 208 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 32 to the 15 allowed.
{
if (_logger != null)
{
Expand Down Expand Up @@ -233,7 +239,7 @@

if (!handled)
{
if (_logger != null)

Check warning on line 242 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Merge this if statement with the enclosing one.

Check warning on line 242 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Merge this if statement with the enclosing one.
{
LogInputNotHandled(_logger, null);
}
Expand Down Expand Up @@ -264,12 +270,12 @@

// Continue processing for recoverable errors
}
catch (OutOfMemoryException)

Check warning on line 273 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add logic to this catch clause or eliminate it and rethrow the exception automatically.

Check warning on line 273 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add logic to this catch clause or eliminate it and rethrow the exception automatically.
{
// Critical error - rethrow
throw;
}
catch (StackOverflowException)

Check warning on line 278 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add logic to this catch clause or eliminate it and rethrow the exception automatically.

Check warning on line 278 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add logic to this catch clause or eliminate it and rethrow the exception automatically.
{
// Critical error - rethrow
throw;
Expand Down
142 changes: 142 additions & 0 deletions TUI.Test/UIApplicationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.TUI.Test;

using ktsu.TUI.Core.Elements.Layouts;
using ktsu.TUI.Core.Elements.Primitives;
using ktsu.TUI.Core.Models;
using ktsu.TUI.Core.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Render-loop tests for <see cref="UIApplication"/>.
/// </summary>
/// <remarks>
/// These cover the rendering model rather than any single element, which is where
/// ktsu-dev/TUI#109 lived: <see cref="UIApplication.Render"/> clears the whole screen on every
/// pass, but elements used to draw only while they were still flagged dirty — so any element the
/// last input did not invalidate was wiped and never redrawn.
/// </remarks>
[TestClass]
public sealed class UIApplicationTests
{
/// <summary>
/// Builds the layout from the issue: a titled border around a static label and an
/// interactive sibling.
/// </summary>
/// <param name="interactive">The interactive sibling, which invalidates itself on input.</param>
/// <returns>The root element, sized and arranged.</returns>
private static BorderElement CreateLayout(out TextElement interactive)
{
TextElement staticLabel = new("Label");
interactive = new TextElement("Input");

StackPanel panel = [];
panel.AddChild(staticLabel);
panel.AddChild(interactive);

BorderElement border = [];
border.Title = "Frame";
border.Position = Position.Origin;
border.Dimensions = new Dimensions(30, 8);
border.AddChild(panel);

// The border was sized after its children were added, so re-arrange to give the labels a
// non-empty content area to draw into.
panel.ArrangeChildren();

return border;
}

/// <summary>
/// A second render pass must draw an element again even though nothing invalidated it. The
/// screen is cleared on every pass, so anything skipped is anything erased.
/// </summary>
[TestMethod]
public void RenderCleanElementTwiceDrawsItTwice()
{
// Arrange
TextElement element = new("Label")
{
Position = Position.Origin,
Dimensions = new Dimensions(20, 1)
};
RecordingConsoleProvider provider = new();

// Act
element.Render(provider);
element.Render(provider);

// Assert
Assert.HasCount(2, provider.WritesOf("Label").ToList(), "A clean element must redraw, not be skipped");
}

/// <summary>
/// The failure scenario from ktsu-dev/TUI#109: an input invalidates the interactive child
/// only, and the static sibling must survive the next pass.
/// </summary>
[TestMethod]
public void RenderAfterOnlyOneChildInvalidatesStillDrawsTheStaticSibling()
{
// Arrange
BorderElement root = CreateLayout(out TextElement interactive);
RecordingConsoleProvider provider = new();
UIApplication application = new(provider);
application.Setup(root);

application.Render();
int writesBeforeSecondPass = provider.Writes.Count;

// Act - an input handled by the interactive child invalidates only that child
interactive.Invalidate();
application.Render();

// Assert
List<string> secondPass = [.. provider.Writes.Skip(writesBeforeSecondPass).Select(w => w.Text)];
Assert.Contains("Label", secondPass, "The static label must be redrawn after the screen is cleared");
Assert.Contains("Input", secondPass, "The invalidated child must be redrawn");
Assert.Contains(" Frame ", secondPass, "The border title must be redrawn");
}

/// <summary>
/// Every clear must be followed by a full redraw, so the number of full-tree redraws keeps
/// pace with the number of clears no matter how many passes run.
/// </summary>
[TestMethod]
public void RenderRedrawsTheWholeTreeOnEveryClear()
{
// Arrange
BorderElement root = CreateLayout(out _);
RecordingConsoleProvider provider = new();
UIApplication application = new(provider);
application.Setup(root);

// Act
application.Render();
application.Render();
application.Render();

// Assert
Assert.AreEqual(3, provider.ClearCount, "Every pass clears the screen");
Assert.HasCount(3, provider.WritesOf("Label").ToList(), "Every clear must be followed by a full redraw");
}

/// <summary>
/// The root element still takes its size from the console on the first pass.
/// </summary>
[TestMethod]
public void RenderAssignsConsoleDimensionsToAnUnsizedRoot()
{
// Arrange
TextElement root = new("Label");
RecordingConsoleProvider provider = new() { Dimensions = new Dimensions(40, 12) };
UIApplication application = new(provider);
application.Setup(root);

// Act
application.Render();

// Assert
Assert.AreEqual(new Dimensions(40, 12), root.Dimensions);
}
}