From 4f579ca450efde6c7be983aa8a131603711d4409 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 20:29:34 +0000 Subject: [PATCH 1/2] fix: draw an element at the position passed to IConsoleProvider.Render [patch] SpectreConsoleProvider.Render moved the cursor to the position and then let the element draw, but every write moves the cursor itself from the element's own Position, so the position was ignored. It then "restored" a cursor read from System.Console rather than the injected console. Render now draws through an OffsetConsoleProvider that shifts every write by the difference between the requested position and the element's own, which moves a container and its children together and leaves the element's layout untouched. The System.Console save and restore is gone. Fixes ktsu-dev/TUI#156 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GJ23oLAT8FnLaMo3MfBviR --- TUI.Test/SpectreConsoleProviderTests.cs | 57 +++++++++++++++++++++++++ TUI/Contracts/IConsoleProvider.cs | 3 +- TUI/Services/OffsetConsoleProvider.cs | 41 ++++++++++++++++++ TUI/Services/SpectreConsoleProvider.cs | 21 ++++----- 4 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 TUI/Services/OffsetConsoleProvider.cs diff --git a/TUI.Test/SpectreConsoleProviderTests.cs b/TUI.Test/SpectreConsoleProviderTests.cs index d2666ad..0f7d78d 100644 --- a/TUI.Test/SpectreConsoleProviderTests.cs +++ b/TUI.Test/SpectreConsoleProviderTests.cs @@ -2,6 +2,7 @@ namespace ktsu.TUI.Test; +using ktsu.TUI.Core.Elements.Primitives; using ktsu.TUI.Core.Models; using ktsu.TUI.Core.Services; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -114,6 +115,62 @@ public void WriteAtClipsTextOverflowingTheRightEdge() Assert.AreEqual("\u001b[3;8Habc", output.ToString()); } + /// + /// Tests that Render draws the element at the position it is given, not at the element's own + /// position, and takes no cursor move from (ktsu-dev/TUI#156). + /// + [TestMethod] + public void RenderDrawsTheElementAtTheGivenPosition() + { + (SpectreConsoleProvider provider, StringWriter output) = CreateProvider(); + TextElement text = new("Hi") { Dimensions = new Dimensions(5, 1) }; + + provider.Render(text, new Position(4, 2)); + + Assert.AreEqual("\u001b[3;5HHi", output.ToString()); + Assert.AreEqual(Position.Origin, text.Position, "Drawing elsewhere must not move the element"); + } + + /// + /// Tests that Render shifts everything a container draws, its children included, by the same + /// amount (ktsu-dev/TUI#156). + /// + [TestMethod] + public void RenderShiftsAContainerAndItsChildrenTogether() + { + (SpectreConsoleProvider provider, StringWriter output) = CreateProvider(); + BorderElement border = []; + border.Position = new Position(1, 1); + border.Dimensions = new Dimensions(4, 3); + border.Child = new TextElement("ab"); + border.ArrangeChildren(); + (SpectreConsoleProvider expectedProvider, StringWriter expected) = CreateProvider(); + border.Position = new Position(5, 2); + border.ArrangeChildren(); + border.Render(expectedProvider); + border.Position = new Position(1, 1); + border.ArrangeChildren(); + + provider.Render(border, new Position(5, 2)); + + Assert.AreEqual(expected.ToString(), output.ToString()); + StringAssert.Contains(output.ToString(), "\u001b[4;7Hab", "The child should be drawn inside the shifted border"); + } + + /// + /// Tests that rendering an element at the position it already has draws it there unchanged. + /// + [TestMethod] + public void RenderAtTheElementsOwnPositionDrawsItThere() + { + (SpectreConsoleProvider provider, StringWriter output) = CreateProvider(); + TextElement text = new("Hi") { Position = new Position(3, 1), Dimensions = new Dimensions(5, 1) }; + + provider.Render(text, new Position(3, 1)); + + Assert.AreEqual("\u001b[2;4HHi", output.ToString()); + } + /// /// Tests that a printable key keeps its typed character as well as its key and modifiers, so /// Shift+1 can be told apart from 1 (ktsu-dev/TUI#152). diff --git a/TUI/Contracts/IConsoleProvider.cs b/TUI/Contracts/IConsoleProvider.cs index 39ca5a4..3fcbd51 100644 --- a/TUI/Contracts/IConsoleProvider.cs +++ b/TUI/Contracts/IConsoleProvider.cs @@ -20,7 +20,8 @@ public interface IConsoleProvider public void Clear(); /// - /// Renders a UI element at the specified position + /// Renders a UI element with its top-left corner at the specified position, wherever its own + /// places it /// /// The UI element to render /// The position to render at diff --git a/TUI/Services/OffsetConsoleProvider.cs b/TUI/Services/OffsetConsoleProvider.cs new file mode 100644 index 0000000..3997ba2 --- /dev/null +++ b/TUI/Services/OffsetConsoleProvider.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.TUI.Core.Services; + +using ktsu.TUI.Core.Contracts; +using ktsu.TUI.Core.Models; + +/// +/// Forwards to another console provider, shifting every position it is given by a fixed offset +/// +/// +/// Elements draw at absolute coordinates taken from their own , +/// so drawing one somewhere else means moving what it writes rather than moving the cursor first: +/// every write places the cursor itself (ktsu-dev/TUI#156). Moving the writes leaves the element +/// and its layout untouched. +/// +/// The provider to draw through +/// How far to shift each position +internal sealed class OffsetConsoleProvider(IConsoleProvider inner, Position offset) : IConsoleProvider +{ + /// + public Dimensions Dimensions => inner.Dimensions; + + /// + public void Clear() => inner.Clear(); + + /// + public void Render(IUIElement element, Position position) => inner.Render(element, position + offset); + + /// + public void WriteAt(string text, Position position, TextStyle? style = null) => inner.WriteAt(text, position + offset, style); + + /// + public Task ReadInputAsync() => inner.ReadInputAsync(); + + /// + public void SetCursorVisibility(bool visible) => inner.SetCursorVisibility(visible); + + /// + public void SetCursorPosition(Position position) => inner.SetCursorPosition(position + offset); +} diff --git a/TUI/Services/SpectreConsoleProvider.cs b/TUI/Services/SpectreConsoleProvider.cs index c47c5dd..4878a84 100644 --- a/TUI/Services/SpectreConsoleProvider.cs +++ b/TUI/Services/SpectreConsoleProvider.cs @@ -35,6 +35,12 @@ internal SpectreConsoleProvider(IAnsiConsole? console, Func read public void Clear() => _console.Clear(); /// + /// + /// Elements draw at their own , and every write moves the + /// cursor itself, so moving the cursor first used to have no effect and the element was drawn + /// where it already was. Its writes are shifted by the difference instead, which leaves the + /// element and its layout untouched (ktsu-dev/TUI#156). + /// public void Render(IUIElement element, Position position) { Ensure.NotNull(element); @@ -44,17 +50,8 @@ public void Render(IUIElement element, Position position) return; } - // Save current cursor position - Position originalPosition = GetCursorPosition(); - - // Set cursor to render position - SetCursorPosition(position); - - // Let the element render itself - element.Render(this); - - // Restore cursor position - SetCursorPosition(originalPosition); + Position offset = position - element.Position; + element.Render(offset == Position.Origin ? this : new OffsetConsoleProvider(this, offset)); } /// @@ -157,8 +154,6 @@ private string ClipToScreen(string text, Position position, out Position visible return text.Substring((int)(visibleStart - start), (int)(visibleEnd - visibleStart)); } - private static Position GetCursorPosition() => new(Console.CursorLeft, Console.CursorTop); - private static Markup CreateStyledMarkup(string text, TextStyle style) { string styleString = BuildStyleString(style); From 78d7cf3e2b31a74123d5c0f14778aaf9f12a30da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 20:40:46 +0000 Subject: [PATCH 2/2] test: cover OffsetConsoleProvider and invisible elements in Render SonarCloud measured 40% coverage on the new code because the provider's pass-through members were never exercised. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GJ23oLAT8FnLaMo3MfBviR --- TUI.Test/OffsetConsoleProviderTests.cs | 79 +++++++++++++++++++++++++ TUI.Test/SpectreConsoleProviderTests.cs | 14 +++++ 2 files changed, 93 insertions(+) create mode 100644 TUI.Test/OffsetConsoleProviderTests.cs diff --git a/TUI.Test/OffsetConsoleProviderTests.cs b/TUI.Test/OffsetConsoleProviderTests.cs new file mode 100644 index 0000000..26e22b7 --- /dev/null +++ b/TUI.Test/OffsetConsoleProviderTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.TUI.Test; + +using ktsu.TUI.Core.Elements.Primitives; +using ktsu.TUI.Core.Models; +using ktsu.TUI.Core.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Spectre.Console; + +/// +/// Tests for , which draws an element somewhere other than its +/// own position by shifting what it writes (ktsu-dev/TUI#156). +/// +[TestClass] +public sealed class OffsetConsoleProviderTests +{ + private static readonly Position Offset = new(3, 2); + + /// + /// Tests that writes and cursor moves are shifted by the offset. + /// + [TestMethod] + public void PositionsAreShiftedByTheOffset() + { + RecordingConsoleProvider inner = new(); + OffsetConsoleProvider provider = new(inner, Offset); + TextStyle style = TextStyle.Default with { IsBold = true }; + + provider.WriteAt("x", new Position(1, 1), style); + provider.SetCursorPosition(new Position(4, 0)); + + Assert.AreEqual(new RecordingConsoleProvider.Write("x", new Position(4, 3), style), inner.Writes.Single()); + Assert.AreEqual(new Position(7, 2), inner.CursorPosition); + } + + /// + /// Tests that everything without a position passes through unchanged. + /// + [TestMethod] + public async Task EverythingElsePassesThroughUnchanged() + { + RecordingConsoleProvider inner = new() { Dimensions = new Dimensions(12, 7) }; + OffsetConsoleProvider provider = new(inner, Offset); + + provider.Clear(); + provider.SetCursorVisibility(false); + InputResult input = await provider.ReadInputAsync().ConfigureAwait(false); + + Assert.AreEqual(new Dimensions(12, 7), provider.Dimensions); + Assert.AreEqual(1, inner.ClearCount); + Assert.IsFalse(inner.CursorVisible); + Assert.AreEqual(new InputResult(), input); + } + + /// + /// Tests that an element rendered through the provider at a position lands at that position + /// plus the offset, so offsets compose. + /// + [TestMethod] + public void NestedRenderAddsTheOffset() + { + StringWriter output = new(); + IAnsiConsole console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(output), + }); + console.Profile.Width = 20; + console.Profile.Height = 10; + OffsetConsoleProvider provider = new(new SpectreConsoleProvider(console), Offset); + + provider.Render(new TextElement("Hi") { Dimensions = new Dimensions(5, 1) }, new Position(1, 1)); + + Assert.AreEqual("\u001b[4;5HHi", output.ToString()); + } +} diff --git a/TUI.Test/SpectreConsoleProviderTests.cs b/TUI.Test/SpectreConsoleProviderTests.cs index 0f7d78d..3c19a7b 100644 --- a/TUI.Test/SpectreConsoleProviderTests.cs +++ b/TUI.Test/SpectreConsoleProviderTests.cs @@ -171,6 +171,20 @@ public void RenderAtTheElementsOwnPositionDrawsItThere() Assert.AreEqual("\u001b[2;4HHi", output.ToString()); } + /// + /// Tests that Render draws nothing for an element that is not visible. + /// + [TestMethod] + public void RenderDrawsNothingForAnInvisibleElement() + { + (SpectreConsoleProvider provider, StringWriter output) = CreateProvider(); + TextElement text = new("Hi") { Dimensions = new Dimensions(5, 1), IsVisible = false }; + + provider.Render(text, new Position(4, 2)); + + Assert.AreEqual(string.Empty, output.ToString()); + } + /// /// Tests that a printable key keeps its typed character as well as its key and modifiers, so /// Shift+1 can be told apart from 1 (ktsu-dev/TUI#152).