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
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Summary
- PrettyConsole/ — main library
- PrettyConsole.Tests/ — interactive/demo runner (manually selects visual feature demos)
- PrettyConsole.Tests.Unit/ — xUnit v3 unit tests using Microsoft Testing Platform
- v5.0.0 (November 2025) removed the legacy `ColoredOutput`/`Color` types; color composition now flows through `ConsoleColor` helpers and tuples exposed by the library.
- v5.1.0 (current) renames `PrettyConsoleExtensions` to `ConsoleContext`, adds `Console.WriteWhiteSpaces(length, pipe)`, and makes `Out`/`Error`/`In` settable for test doubles. v5.0.0 removed the legacy `ColoredOutput`/`Color` types; color composition now flows through `ConsoleColor` helpers and tuples exposed by the library.

Commands you’ll use often

Expand Down Expand Up @@ -44,19 +44,19 @@ Repo-specific agent rules and conventions
High-level architecture and key concepts

- Console facade
- Extension members declared via `extension(Console)` attach directly to `System.Console`, so APIs such as `Console.WriteInterpolated`, `Console.TryReadLine`, `Console.Overwrite`, etc. light up once `using PrettyConsole;` (optionally with `using static System.Console;`) is in scope. `PrettyConsoleExtensions` still exposes the live `In`, `Out`, and `Error` streams plus helpers like `GetWidthOrDefault`.
- Extension members declared via `extension(Console)` attach directly to `System.Console`, so APIs such as `Console.WriteInterpolated`, `Console.TryReadLine`, `Console.Overwrite`, etc. light up once `using PrettyConsole;` (optionally with `using static System.Console;`) is in scope. `ConsoleContext` exposes the live `In`, `Out`, and `Error` streams (all now settable for testing) plus helpers like `GetWidthOrDefault`.
- Output routing
- `OutputPipe` is a two-value enum (`Out`, `Error`). Most write APIs accept an optional pipe; internally `PrettyConsoleExtensions.GetWriter` resolves the correct `TextWriter` so sequences remain redirect-friendly.
- `OutputPipe` is a two-value enum (`Out`, `Error`). Most write APIs accept an optional pipe; internally `ConsoleContext.GetWriter` resolves the correct `TextWriter` so sequences remain redirect-friendly.
- Interpolated string handler
- `PrettyConsoleInterpolatedStringHandler` enables zero-allocation `$"..."` calls for `WriteInterpolated`, `WriteLineInterpolated`, `ReadLine`, `TryReadLine`, `Confirm`, and `RequestAnyInput`. Colors automatically reset after each invocation, and handlers respect the selected pipe and optional `IFormatProvider`. Recent changes ensure any `object` argument that implements `ISpanFormattable` is emitted through the span-based path before falling back to `IFormattable`/string allocations.
- `PrettyConsoleInterpolatedStringHandler` enables zero-allocation `$"..."` calls for `WriteInterpolated`, `WriteLineInterpolated`, `ReadLine`, `TryReadLine`, `Confirm`, and `RequestAnyInput`. Colors automatically reset after each invocation, and handlers respect the selected pipe and optional `IFormatProvider`. Recent changes ensure any `object` argument that implements `ISpanFormattable` is emitted through the span-based path before falling back to `IFormattable`/string allocations. `Console.WriteInterpolated`/`WriteLineInterpolated` now return the rendered character count (escape sequences emitted by the handler are excluded), which can be used for padding/layout math.
- Coloring model
- `ConsoleColor` now exposes `DefaultForeground`, `DefaultBackground`, and `Default` tuple properties plus `/` operator overloads so you can inline foreground/background tuples (`$"{ConsoleColor.Red / ConsoleColor.White}Error"`). These tuples play nicely with the interpolated string handler and keep color resets allocation-free.
- Markup decorations
- The `Markup` static class exposes ANSI sequences for underline, bold, italic, and strikethrough. Fields expand to escape codes only when output/error aren’t redirected; otherwise they collapse to empty strings so callers can safely interpolate them without extra checks.
- Write APIs
- `WriteInterpolated`/`WriteLineInterpolated` host the interpolated-string handler; `Write`/`WriteLine` overloads target `ISpanFormattable` values (including `ref struct`s) and raw `ReadOnlySpan<char>` spans with optional foreground/background overrides. Implementations rent buffers via `BufferPool` to avoid allocation spikes and always reset colors.
- `WriteInterpolated`/`WriteLineInterpolated` host the interpolated-string handler; `Write`/`WriteLine` overloads target `ISpanFormattable` values (including `ref struct`s) and raw `ReadOnlySpan<char>` spans with optional foreground/background overrides. Implementations rent buffers from `ArrayPool<char>.Shared` to avoid allocation spikes and always reset colors.
- TextWriter helpers
- `PrettyConsoleExtensions` surfaces a `TextWriter.WriteWhiteSpaces(int)` extension (available via `PrettyConsoleExtensions.Out/Error`) for allocation-free padding. Use it instead of creating temporary `string`s when building menus, tables, or progress output.
- `ConsoleContext` surfaces the live `Out`/`Error` writers (now with public setters for test doubles) and keeps helpers like `GetWidthOrDefault`. Use `Console.WriteWhiteSpaces(int length, OutputPipe pipe = OutputPipe.Out)` for direct padding from call sites; `TextWriter.WriteWhiteSpaces(int)` remains available on the writers if you already have them on hand.
- Inputs
- `ReadLine`/`TryReadLine` support `IParsable<T>` types, optional defaults, enum parsing with `ignoreCase`, and interpolated prompts. `Confirm` exposes `DefaultConfirmValues`, overloads for custom truthy tokens, and interpolated prompts; `RequestAnyInput` blocks on `ReadKey` with colored prompts if desired.
- Rendering controls
Expand Down
20 changes: 16 additions & 4 deletions Benchmarks/Config.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
using BenchmarkDotNet.Order;
using BenchmarkDotNet.Reports;
using BenchmarkDotNet.Running;
using BenchmarkDotNet.Toolchains.NativeAot;

using Perfolizer.Horology;

using Perfolizer.Mathematics.OutlierDetection;

namespace Benchmarks;
Expand All @@ -21,12 +21,23 @@ public Config() {
UnionRule = ConfigUnionRule.AlwaysUseLocal;
SummaryStyle = SummaryStyle.Default.WithRatioStyle(RatioStyle.Trend);
AddDiagnoser(MemoryDiagnoser.Default);
AddJob(Job.Default
var baseJob = Job.Default
.WithId("PGO1")
.WithOutlierMode(OutlierMode.RemoveAll)
.WithLaunchCount(3)
.WithWarmupCount(5)
.WithIterationCount(30)
.WithIterationTime(TimeInterval.FromMilliseconds(100)));
.WithIterationTime(TimeInterval.FromMilliseconds(100))
.WithEnvironmentVariable("DOTNET_TieredPGO", "1"); // default, explicit for clarity

AddJob(baseJob);
AddJob(baseJob
.WithId("PGO2")
.WithEnvironmentVariable("DOTNET_TieredPGO", "2"));
AddJob(baseJob
.WithId("NativeAOT")
.WithEnvironmentVariable("DOTNET_TieredPGO", "0") // not applicable, but keep deterministic
.WithToolchain(NativeAotToolchain.Net10_0));
AddColumnProvider(DefaultColumnProviders.Instance);
HideColumns(Column.Error, Column.StdDev, Column.Median, Column.RatioSD);
WithOrderer(new GroupByTypeOrderer());
Expand All @@ -48,7 +59,8 @@ public IEnumerable<BenchmarkCase> GetExecutionOrder(
// Sort rows in the summary: first by Type, then Method, then Params
public IEnumerable<BenchmarkCase> GetSummaryOrder(
ImmutableArray<BenchmarkCase> cases, Summary summary) =>
cases.OrderBy(c => c.Descriptor.Type.FullName)
cases.OrderBy(c => c.Job.Id)
.ThenBy(c => c.Descriptor.Type.FullName)
.ThenBy(c => c.Parameters.DisplayInfo);

// We don’t use highlight groups
Expand Down
4 changes: 2 additions & 2 deletions Benchmarks/StyledOutputBenchmark.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public class StyledOutputBenchmarks {
[GlobalSetup]
public void GlobalSetup() {
_outputWriter = Console.Out;
PrettyConsoleExtensions.Out = TextWriter.Null;
ConsoleContext.Out = TextWriter.Null;
_ansiConsole = AnsiConsole.Create(new AnsiConsoleSettings {
Out = new AnsiConsoleOutput(TextWriter.Null)
});
Expand All @@ -32,7 +32,7 @@ public void GlobalSetup() {

[GlobalCleanup]
public void GlobalCleanup() {
PrettyConsoleExtensions.Out = _outputWriter;
ConsoleContext.Out = _outputWriter;
_ansiConsole = AnsiConsole.Console;
Console.SetOut(_outputWriter);
}
Expand Down
2 changes: 1 addition & 1 deletion PrettyConsole.Tests.Unit/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
global using Xunit;

global using static System.ConsoleColor;
global using static PrettyConsole.PrettyConsoleExtensions;
global using static PrettyConsole.ConsoleContext;
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,91 @@ public void AppendFormatted_DoubleBytes_WritesExpected(double value) {
Assert.Equal($"Size {FormatBytes(value)}", _writer.ToStringAndFlush());
}

[Fact]
public void CharsWritten_IgnoresAnsiColorAndMarkupSequences() {
int chars = Console.WriteInterpolated($"{ConsoleColor.Red}{Markup.Bold}Hi{Markup.Reset}");

var written = Utilities.StripAnsiSequences(_writer.ToStringAndFlush());

Assert.Equal("Hi", written);
Assert.Equal(2, chars);
}

[Theory]
[InlineData(5, " OK")]
[InlineData(-5, "OK ")]
public void Alignment_UsesVisibleLengthWhenMarkupPresent(int alignment, string expected) {
int chars = alignment > 0
? Console.WriteInterpolated($"{Markup.Bold}{"OK",5}{Markup.Reset}")
: Console.WriteInterpolated($"{Markup.Bold}{"OK",-5}{Markup.Reset}");

var written = Utilities.StripAnsiSequences(_writer.ToStringAndFlush());

Assert.Equal(expected, written);
Assert.Equal(expected.Length, chars);
}

[Fact]
public void WriteLineInterpolated_ReturnsCharsWithoutNewline() {
int chars = Console.WriteLineInterpolated($"Hi");

var written = Utilities.StripAnsiSequences(_writer.ToStringAndFlush());

Assert.Equal("Hi" + Environment.NewLine, written);
Assert.Equal(2, chars);
}

[Fact]
public void WriteInterpolated_MixedPrimitivesAndFormats_ReturnsVisibleCount() {
var duration = TimeSpan.FromMinutes(5) + TimeSpan.FromSeconds(7);

int chars = Console.WriteInterpolated($"Id:{123} Ok:{true} Pi:{3.14159:F2} Char:{'X'} Elapsed:{duration:duration}");

var expected = $"Id:123 Ok:True Pi:3.14 Char:X Elapsed:0h 5m 7s";
Assert.Equal(expected, _writer.ToStringAndFlush());
Assert.Equal(expected.Length, chars);
}

[Fact]
public void WriteInterpolated_ColorTuple_DoesNotAffectVisibleCount() {
int chars = Console.WriteInterpolated($"{(ConsoleColor.Red, ConsoleColor.White)}ERR{ConsoleColor.Default} done");

var written = Utilities.StripAnsiSequences(_writer.ToStringAndFlush());

Assert.Equal("ERR done", written);
Assert.Equal("ERR done".Length, chars);
}

[Theory]
[InlineData(6)]
[InlineData(-6)]
public void WriteInterpolated_ColorTupleAlignment_UsesWidthOnly(int alignment) {
int chars = alignment > 0
? Console.WriteInterpolated($"{(ConsoleColor.Blue, ConsoleColor.White),6}")
: Console.WriteInterpolated($"{(ConsoleColor.Blue, ConsoleColor.White),-6}");

var written = Utilities.StripAnsiSequences(_writer.ToStringAndFlush());
var expected = new string(' ', 6);

Assert.Equal(expected, written);
Assert.Equal(6, chars);
}

[Fact]
public void WriteLineInterpolated_WithColorsAndPrimitives_CountExcludesNewline() {
var duration = TimeSpan.FromSeconds(42);
var writer = new StringWriter();
Error = writer;

int chars = Console.WriteLineInterpolated(OutputPipe.Error, $"{ConsoleColor.Yellow}[{duration:duration}] {Markup.Bold}done{Markup.Reset}");

var stripped = Utilities.StripAnsiSequences(writer.ToString());
var expected = $"[0h 0m 42s] done{Environment.NewLine}";

Assert.Equal(expected, stripped);
Assert.Equal("[0h 0m 42s] done".Length, chars);
}

private static string FormatBytes(double value) {
const double formatBytesKb = 1024d;
var suffix = 0;
Expand Down
66 changes: 66 additions & 0 deletions PrettyConsole/ConsoleContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System.Runtime.Versioning;

namespace PrettyConsole;

/// <summary>
/// The static class the provides the abstraction over <see cref="Console"/> and other extensions.
/// </summary>
[UnsupportedOSPlatform("android")]
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
public static class ConsoleContext {
/// <summary>
/// The standard output stream.
/// </summary>
public static TextWriter Out { get; set; } = Console.Out;

/// <summary>
/// The standard error stream.
/// </summary>
public static TextWriter Error { get; set; } = Console.Error;

/// <summary>
/// The standard input stream.
/// </summary>
public static TextReader In { get; set; } = Console.In;

/// <summary>
/// Gets the appropriate <see cref="TextWriter"/> based on <paramref name="pipe"/>
/// </summary>
/// <param name="pipe"></param>
internal static TextWriter GetWriter(OutputPipe pipe)
=> pipe switch {
OutputPipe.Error => Error,
_ => Out
};

/// <summary>
/// Returns the current console buffer width or <paramref name="defaultWidth"/> if <see cref="Console.IsOutputRedirected"/>
/// </summary>
/// <param name="defaultWidth"></param>
internal static int GetWidthOrDefault(int defaultWidth = 120) {
if (Console.IsOutputRedirected) {
return defaultWidth;
}
return Console.BufferWidth;
}

extension(TextWriter @this) {
/// <summary>
/// Writes whitespace to this <see cref="TextWriter"/> up to length by chucks
/// </summary>
/// <param name="length"></param>
public void WriteWhiteSpaces(int length) {
ReadOnlySpan<char> whiteSpaces = WhiteSpaces;

while (length > 0) {
int curLength = Math.Min(length, 256);
@this.Write(whiteSpaces.Slice(0, curLength));
length -= curLength;
}
}
}

private static readonly string WhiteSpaces = new(' ', 256);
}
43 changes: 0 additions & 43 deletions PrettyConsole/ConsolePipes.cs

This file was deleted.

3 changes: 1 addition & 2 deletions PrettyConsole/IndeterminateProgressBar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ public async Task RunAsync(Task task, string header, CancellationToken token = d
while (!task.IsCompleted && !token.IsCancellationRequested) {
try {
Console.ForegroundColor = ForegroundColor;
PrettyConsoleExtensions.Error.Write(AnimationSequence[seqIndex]);
ConsoleContext.Error.Write(AnimationSequence[seqIndex]);
} finally {
Console.ForegroundColor = originalColor;
}
Expand Down Expand Up @@ -164,7 +164,6 @@ public async Task RunAsync(Task task, string header, CancellationToken token = d
Console.ResetColor();
}

[MethodImpl(MethodImplOptions.NoInlining)]
private Task RunAsyncNonGeneric(Task task, string header, CancellationToken token) => RunAsync(task, header, token);

/// <summary>
Expand Down
6 changes: 2 additions & 4 deletions PrettyConsole/InputRequestExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,7 @@ public static void RequestAnyInput([InterpolatedStringHandlerArgument] PrettyCon
/// <remarks>
/// It does not display a question mark or any other prompt, only the message
/// </remarks>
public static bool Confirm([InterpolatedStringHandlerArgument] PrettyConsoleInterpolatedStringHandler handler = default) {
return Confirm(DefaultConfirmValues, true, handler);
}
public static bool Confirm([InterpolatedStringHandlerArgument] PrettyConsoleInterpolatedStringHandler handler = default) => Confirm(DefaultConfirmValues, true, handler);

/// <summary>
/// Used to get user confirmation
Expand All @@ -51,7 +49,7 @@ public static bool Confirm([InterpolatedStringHandlerArgument] PrettyConsoleInte
/// </remarks>
public static bool Confirm(ReadOnlySpan<string> trueValues, bool emptyIsTrue = true, [InterpolatedStringHandlerArgument] PrettyConsoleInterpolatedStringHandler handler = default) {
handler.ResetColors();
var input = PrettyConsoleExtensions.In.ReadLine();
var input = ConsoleContext.In.ReadLine();
if (input is null or { Length: 0 }) {
return emptyIsTrue;
}
Expand Down
Loading