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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,11 @@ Testing structure and workflows
- `Program.cs` allows to test things that need to be verified visually and can't be tested easily or at all using unit tests. It contains tests for various things like menues, tables, progress bar, etc... and at occations new overloads and other things. It's content doesn't need to be tracked, it is more like a playground.
- PrettyConsole.Tests.Unit (xUnit v3)
- Uses Microsoft.NET.Test.Sdk with the Microsoft Testing Platform runner; xunit.runner.json is included. Execute with dotnet run as shown above; pass filters after to narrow to a class or method.
- Progress bar coverage now includes multi-line rendering (`sameLine: false`), repeat renders at the same percentage, and the static `ProgressBar.WriteProgressBar` helper. Keep these behaviours in sync with docs.

Notes and gotchas

- The library aims to minimize allocations; prefer span-based overloads (ReadOnlySpan<char>, ReadOnlySpan<ColoredOutput>) for best performance when contributing.
- When authoring new features, pick the appropriate OutputPipe to keep CLI piping behavior intact.
- On macOS terminals, ANSI is supported; Windows legacy terminals are handled via ANSI-compatible rendering in the library.
- `ProgressBar.Update` re-renders on every call (even when the percentage is unchanged) and accepts `sameLine` to place the status above the bar; the static `ProgressBar.WriteProgressBar` renders one-off bars for multi-progress scenarios.
46 changes: 46 additions & 0 deletions PrettyConsole.Tests.Unit/BufferPoolTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace PrettyConsole.Tests.Unit;

public class BufferPoolTests {
[Fact]
public void Rent_ReturnsFastItemAfterReturn() {
using var pool = CreatePool();

var owner = pool.Rent(out var firstBuffer);
firstBuffer.Add('x');
owner.Dispose();

using var secondOwner = pool.Rent(out var reusedBuffer);

Assert.Same(firstBuffer, reusedBuffer);
Assert.Empty(reusedBuffer);
}

[Fact]
public void Return_DropsOversizedLists() {
using var pool = CreatePool();

List<char> oversized;
using (var owner = pool.Rent(out var buffer)) {
oversized = buffer;
buffer.AddRange(new string('x', BufferPool.ListMaxSize + 1));
}

using var nextOwner = pool.Rent(out var nextBuffer);

Assert.NotSame(oversized, nextBuffer);
Assert.Equal(BufferPool.ListStartingSize, nextBuffer.Capacity);
}

[Fact]
public void Value_ThrowsAfterDispose() {
using var pool = CreatePool();

var owner = pool.Rent(out _);
owner.Dispose();

Assert.Throws<InvalidOperationException>(() => _ = owner.Value);
}

private static BufferPool CreatePool()
=> (BufferPool)Activator.CreateInstance(typeof(BufferPool), nonPublic: true)!;
}
58 changes: 53 additions & 5 deletions PrettyConsole.Tests.Unit/ProgressBarTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,66 @@ public void ProgressBar_Update_WritesStatusAndPercentage() {
}

[Fact]
public void ProgressBar_Update_SamePercentage_NoAdditionalOutput() {
public void ProgressBar_Update_SamePercentage_RerendersOutput() {
Utilities.SkipIfNoInteractiveConsole();
Error = Utilities.GetWriter(out var errorWriter);

var bar = new ProgressBar();
var bar = new ProgressBar {
ProgressChar = '#',
ForegroundColor = ConsoleColor.White,
ProgressColor = ConsoleColor.Green
};

bar.Update(25);
bar.Update(25, "Loading");
errorWriter.ToStringAndFlush();

bar.Update(25);
bar.Update(25, "Loading");

var output = errorWriter.ToString();
Assert.NotEqual(string.Empty, output);
Assert.Contains("Loading", output);
Assert.Contains("25", output);
}

[Fact]
public void ProgressBar_Update_SameLineFalse_WritesStatusOnSeparateLine() {
Utilities.SkipIfNoInteractiveConsole();

var originalError = Error;
try {
Error = Utilities.GetWriter(out var errorWriter);

var bar = new ProgressBar {
ProgressChar = '#'
};

bar.Update(75, "Working", sameLine: false);

var output = errorWriter.ToString();
Assert.Contains("Working", output);
Assert.Contains(Environment.NewLine + "[", output);
} finally {
Error = originalError;
}
}

[Fact]
public void ProgressBar_WriteProgressBar_WritesFormattedOutput() {
Utilities.SkipIfNoInteractiveConsole();

var originalOut = Out;
try {
Out = Utilities.GetWriter(out var outWriter);

ProgressBar.WriteProgressBar(OutputPipe.Out, 75, ConsoleColor.Cyan, '*');

Assert.Equal(string.Empty, errorWriter.ToString());
var output = outWriter.ToString();
Assert.Contains("[", output);
Assert.Contains("75%", output);
Assert.Contains("*", output);
} finally {
Out = originalOut;
}
}

[Fact]
Expand Down
28 changes: 28 additions & 0 deletions PrettyConsole.Tests/Features/MultiProgressBarTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using static PrettyConsole.Console;

namespace PrettyConsole.Tests.Features;

public sealed class MultiProgressBarTest : IPrettyConsoleTest {
public string FeatureName => "MultiProgressBar";

public async ValueTask Implementation() {
const int count = 333;
var currentLine = GetCurrentLine();
for (int i = 1; i <= count; i++) {
double percentage = 100 * (double)i / count;

Overwrite((int)percentage, p => {
Write(OutputPipe.Error, $"Task {1}: ");
ProgressBar.WriteProgressBar(OutputPipe.Error, p, Color.Magenta);
NewLine(OutputPipe.Error);
Write(OutputPipe.Error, $"Task {2}: ");
ProgressBar.WriteProgressBar(OutputPipe.Error, p, Color.Magenta);
NewLine(OutputPipe.Error);
}, 2);

await Task.Delay(15);
}
ClearNextLines(2, OutputPipe.Error);
WriteLine(OutputPipe.Error, $"Done");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,23 @@

namespace PrettyConsole.Tests.Features;

public sealed class ProgressBarTest : IPrettyConsoleTest {
public string FeatureName => "ProgressBar";
/// <summary>
/// Uses the default update method parameter (same line)
/// </summary>
public sealed class ProgressBarDefaultTest : IPrettyConsoleTest {
public string FeatureName => "ProgressBarDefault";

public async ValueTask Implementation() {
var prg = new ProgressBar {
ProgressColor = Color.Magenta,
// ProgressChar = '🧎‍♂️‍➡️'
};
const int count = 333;
var currentLine = GetCurrentLine();
for (int i = 1; i <= count; i++) {
double percentage = 100 * (double)i / count;
prg.Update(percentage, "TESTING");
await Task.Delay(15);
}
ClearNextLines(1, OutputPipe.Error);
GoToLine(currentLine);
WriteLine(OutputPipe.Error, $"Done");
}
}
24 changes: 24 additions & 0 deletions PrettyConsole.Tests/Features/ProgressBarMultiLineTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using static PrettyConsole.Console;

namespace PrettyConsole.Tests.Features;

/// <summary>
/// Configures sameLine = false
/// </summary>
public sealed class ProgressBarMultiLineTest : IPrettyConsoleTest {
public string FeatureName => "ProgressBarMultiLine";

public async ValueTask Implementation() {
var prg = new ProgressBar {
ProgressColor = Color.Magenta,
};
const int count = 333;
for (int i = 1; i <= count; i++) {
double percentage = 100 * (double)i / count;
prg.Update(percentage, "TESTING", false);
await Task.Delay(15);
}
ClearNextLines(2, OutputPipe.Error);
WriteLine(OutputPipe.Error, $"Done");
}
}
4 changes: 3 additions & 1 deletion PrettyConsole.Tests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
new TableTest(),
new TreeMenuTest(),
new IndeterminateProgressBarTest(),
new ProgressBarTest()
new ProgressBarDefaultTest(),
new ProgressBarMultiLineTest(),
new MultiProgressBarTest(),
};

foreach (var test in tests) {
Expand Down
12 changes: 9 additions & 3 deletions PrettyConsole/AdvancedOutputs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ public static partial class Console {
/// </summary>
/// <param name="output"></param>
/// <param name="pipe">The output pipe to use</param>
[MethodImpl(MethodImplOptions.Synchronized)]
/// <remarks>
/// Please remember to clear the used lines after the last call to this method, you can use <see cref="ClearNextLines"/>
/// </remarks>
public static void OverwriteCurrentLine(ReadOnlySpan<ColoredOutput> output, OutputPipe pipe = OutputPipe.Error) {
var currentLine = GetCurrentLine();
ClearNextLines(1, pipe);
Expand All @@ -20,7 +22,9 @@ public static void OverwriteCurrentLine(ReadOnlySpan<ColoredOutput> output, Outp
/// <param name="action">The output action.</param>
/// <param name="lines">The amount of lines to clear.</param>
/// <param name="pipe">The output pipe to use.</param>
[MethodImpl(MethodImplOptions.Synchronized)]
/// <remarks>
/// Please remember to clear the used lines after the last call to this method, you can use <see cref="ClearNextLines"/>
/// </remarks>
public static void Overwrite(Action action, int lines = 1, OutputPipe pipe = OutputPipe.Error) {
var currentLine = GetCurrentLine();
ClearNextLines(lines, pipe);
Expand All @@ -36,7 +40,9 @@ public static void Overwrite(Action action, int lines = 1, OutputPipe pipe = Out
/// <param name="action">The output action.</param>
/// <param name="lines">The amount of lines to clear.</param>
/// <param name="pipe">The output pipe to use.</param>
[MethodImpl(MethodImplOptions.Synchronized)]
/// <remarks>
/// Please remember to clear the used lines after the last call to this method, you can use <see cref="ClearNextLines"/>
/// </remarks>
public static void Overwrite<TState>(TState state, Action<TState> action, int lines = 1, OutputPipe pipe = OutputPipe.Error) where TState : allows ref struct {
var currentLine = GetCurrentLine();
ClearNextLines(lines, pipe);
Expand Down
39 changes: 18 additions & 21 deletions PrettyConsole/BufferPool.cs
Original file line number Diff line number Diff line change
@@ -1,37 +1,24 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading.Channels;

namespace PrettyConsole;

internal sealed class BufferPool : IDisposable {
private bool _disposed;
private readonly Channel<List<char>> _channel;
private readonly Func<List<char>> _createPolicy;
private readonly Func<List<char>, bool> _returnPolicy;
private readonly ThreadLocal<List<char>?> _fastItem;

internal const int ListStartingSize = 256;
internal const int ListMaxSize = 4096;

public static readonly BufferPool Shared
= new(() => new(ListStartingSize),
item => {
if (item.Count > ListMaxSize) {
return false;
}
item.Clear();
return true;
});

private BufferPool(Func<List<char>> createPolicy, Func<List<char>, bool> returnPolicy) {
public static readonly BufferPool Shared = new();

private BufferPool() {
_channel = Channel.CreateBounded<List<char>>(new BoundedChannelOptions(Environment.ProcessorCount * 2) {
SingleWriter = false,
SingleReader = false,
FullMode = BoundedChannelFullMode.DropWrite
});
_createPolicy = createPolicy ?? throw new ArgumentNullException(nameof(createPolicy));
_returnPolicy = returnPolicy ?? throw new ArgumentNullException(nameof(returnPolicy));
_fastItem = new(() => null, trackAllValues: false);
}

Expand All @@ -48,24 +35,34 @@ public PooledObjectOwner Rent(out List<char> value) {
value = item;
return new(this, value);
}
value = _createPolicy();
value = new List<char>(ListStartingSize);
return new(this, value);
}

[MethodImpl(MethodImplOptions.NoInlining)]
private void Return(List<char> item) {
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_returnPolicy(item)) {
(item as IDisposable)?.Dispose();
if (!AcceptAndClear(item)) {
return;
}
if (_fastItem.Value is null) {
_fastItem.Value = item;
return;
}
if (!_channel.Writer.TryWrite(item)) {
(item as IDisposable)?.Dispose();
_channel.Writer.TryWrite(item);
}

/// <summary>
/// Checks if <paramref name="item"/> should be accepted back to the pool, and clears it if it should.
/// </summary>
/// <param name="item"></param>
/// <returns></returns>
private static bool AcceptAndClear(List<char> item) {
if (item.Count > ListMaxSize) {
return false;
}
item.Clear();
return true;
}

public void Dispose() {
Expand Down
20 changes: 20 additions & 0 deletions PrettyConsole/Extensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace PrettyConsole;

internal static class Extensions {
private static readonly string WhiteSpaces = new(' ', 256);

/// <summary>
/// Writes whitespace to a <see cref="TextWriter"/> up to length by chucks
/// </summary>
/// <param name="writer"></param>
/// <param name="length"></param>
internal static void WriteWhiteSpaces(this TextWriter writer, int length) {
ReadOnlySpan<char> whiteSpaces = WhiteSpaces;

while (length > 0) {
int cur_length = Math.Min(length, 256);
writer.Write(whiteSpaces.Slice(0, cur_length));
length -= cur_length;
}
}
}
Loading