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 d2666ad..3c19a7b 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,76 @@ 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 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). 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);