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);