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
79 changes: 79 additions & 0 deletions TUI.Test/OffsetConsoleProviderTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for <see cref="OffsetConsoleProvider"/>, which draws an element somewhere other than its
/// own position by shifting what it writes (ktsu-dev/TUI#156).
/// </summary>
[TestClass]
public sealed class OffsetConsoleProviderTests
{
private static readonly Position Offset = new(3, 2);

/// <summary>
/// Tests that writes and cursor moves are shifted by the offset.
/// </summary>
[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);
}

/// <summary>
/// Tests that everything without a position passes through unchanged.
/// </summary>
[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);
}

/// <summary>
/// Tests that an element rendered through the provider at a position lands at that position
/// plus the offset, so offsets compose.
/// </summary>
[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());
}
}
71 changes: 71 additions & 0 deletions TUI.Test/SpectreConsoleProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -114,6 +115,76 @@
Assert.AreEqual("\u001b[3;8Habc", output.ToString());
}

/// <summary>
/// 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 <see cref="Console"/> (ktsu-dev/TUI#156).
/// </summary>
[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");
}

/// <summary>
/// Tests that Render shifts everything a container draws, its children included, by the same
/// amount (ktsu-dev/TUI#156).
/// </summary>
[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");

Check warning on line 157 in TUI.Test/SpectreConsoleProviderTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_TUI&issues=AaEYF4VLIOn3IWUyTj9p&open=AaEYF4VLIOn3IWUyTj9p&pullRequest=204
}

/// <summary>
/// Tests that rendering an element at the position it already has draws it there unchanged.
/// </summary>
[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());
}

/// <summary>
/// Tests that Render draws nothing for an element that is not visible.
/// </summary>
[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());
}

/// <summary>
/// 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).
Expand Down
3 changes: 2 additions & 1 deletion TUI/Contracts/IConsoleProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public interface IConsoleProvider
public void Clear();

/// <summary>
/// Renders a UI element at the specified position
/// Renders a UI element with its top-left corner at the specified position, wherever its own
/// <see cref="IUIElement.Position"/> places it
/// </summary>
/// <param name="element">The UI element to render</param>
/// <param name="position">The position to render at</param>
Expand Down
41 changes: 41 additions & 0 deletions TUI/Services/OffsetConsoleProvider.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Forwards to another console provider, shifting every position it is given by a fixed offset
/// </summary>
/// <remarks>
/// Elements draw at absolute coordinates taken from their own <see cref="IUIElement.Position"/>,
/// 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.
/// </remarks>
/// <param name="inner">The provider to draw through</param>
/// <param name="offset">How far to shift each position</param>
internal sealed class OffsetConsoleProvider(IConsoleProvider inner, Position offset) : IConsoleProvider
{
/// <inheritdoc />
public Dimensions Dimensions => inner.Dimensions;

/// <inheritdoc />
public void Clear() => inner.Clear();

/// <inheritdoc />
public void Render(IUIElement element, Position position) => inner.Render(element, position + offset);

/// <inheritdoc />
public void WriteAt(string text, Position position, TextStyle? style = null) => inner.WriteAt(text, position + offset, style);

/// <inheritdoc />
public Task<InputResult> ReadInputAsync() => inner.ReadInputAsync();

/// <inheritdoc />
public void SetCursorVisibility(bool visible) => inner.SetCursorVisibility(visible);

/// <inheritdoc />
public void SetCursorPosition(Position position) => inner.SetCursorPosition(position + offset);
}
21 changes: 8 additions & 13 deletions TUI/Services/SpectreConsoleProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ internal SpectreConsoleProvider(IAnsiConsole? console, Func<ConsoleKeyInfo> read
public void Clear() => _console.Clear();

/// <inheritdoc />
/// <remarks>
/// Elements draw at their own <see cref="IUIElement.Position"/>, 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).
/// </remarks>
public void Render(IUIElement element, Position position)
{
Ensure.NotNull(element);
Expand All @@ -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));
}

/// <inheritdoc />
Expand Down Expand Up @@ -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);
Expand Down
Loading