From dc6bd06d20a5e5c5cba926f907ea263da1bf3003 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Thu, 26 Mar 2026 18:35:10 +0200 Subject: [PATCH 01/16] Initial implementation --- .../TransientConsoleRegionTests.cs | 122 ++++++++++++++ .../PrettyConsoleInterpolatedStringHandler.cs | 13 +- PrettyConsole/TransientConsoleRegion.cs | 154 ++++++++++++++++++ 3 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 PrettyConsole.UnitTests/TransientConsoleRegionTests.cs create mode 100644 PrettyConsole/TransientConsoleRegion.cs diff --git a/PrettyConsole.UnitTests/TransientConsoleRegionTests.cs b/PrettyConsole.UnitTests/TransientConsoleRegionTests.cs new file mode 100644 index 0000000..0844222 --- /dev/null +++ b/PrettyConsole.UnitTests/TransientConsoleRegionTests.cs @@ -0,0 +1,122 @@ +namespace PrettyConsole.UnitTests; + +[SkipWhenConsoleUnavailable] +public class TransientConsoleRegionTests { + [Test] + public async Task Render_WritesSnapshotAndMarksRegionActive() { + var originalError = Error; + int cursorLine = 0; + RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); + try { + Error = Utilities.GetWriter(out var errorWriter); + using var region = new TransientConsoleRegion(); + + region.Render($"Working"); + + await Assert.That(Utilities.StripAnsiSequences(errorWriter.ToString())).Contains("Working"); + await Assert.That(region.IsActive).IsTrue(); + await Assert.That(region.OccupiedLines).IsEqualTo(1); + } finally { + Error = originalError; + RenderingExtensions.ConfigureCursorAccessors(null, null); + } + } + + [Test] + public async Task WriteLine_WhileActive_WritesDurableOutputAndRestoresRegion() { + var originalError = Error; + int cursorLine = 0; + RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); + try { + Error = Utilities.GetWriter(out var errorWriter); + using var region = new TransientConsoleRegion(); + + region.Render($"Loading"); + errorWriter.ToStringAndFlush(); + + region.WriteLine($"Updated serde"); + + var output = Utilities.StripAnsiSequences(errorWriter.ToString()); + await Assert.That(output).Contains("Updated serde"); + await Assert.That(CountOccurrences(output, "Loading")).IsEqualTo(1); + await Assert.That(region.IsActive).IsTrue(); + } finally { + Error = originalError; + RenderingExtensions.ConfigureCursorAccessors(null, null); + } + } + + [Test] + public async Task Write_WhileActive_WritesDurableOutputWithoutRedrawingImmediately() { + var originalError = Error; + int cursorLine = 0; + RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); + try { + Error = Utilities.GetWriter(out var errorWriter); + using var region = new TransientConsoleRegion(); + + region.Render($"Loading"); + errorWriter.ToStringAndFlush(); + + region.Write($"step "); + + var output = Utilities.StripAnsiSequences(errorWriter.ToString()); + await Assert.That(output).Contains("step "); + await Assert.That(output).DoesNotContain("Loading"); + await Assert.That(region.IsActive).IsTrue(); + } finally { + Error = originalError; + RenderingExtensions.ConfigureCursorAccessors(null, null); + } + } + + [Test] + public async Task Clear_RemovesSnapshotAndMarksRegionInactive() { + var originalError = Error; + int cursorLine = 0; + RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); + try { + Error = Utilities.GetWriter(out var errorWriter); + using var region = new TransientConsoleRegion(); + + region.Render($"Loading"); + errorWriter.ToStringAndFlush(); + + region.Clear(); + + await Assert.That(region.IsActive).IsFalse(); + await Assert.That(region.OccupiedLines).IsEqualTo(0); + await Assert.That(errorWriter.ToString()).IsNotEqualTo(string.Empty); + } finally { + Error = originalError; + RenderingExtensions.ConfigureCursorAccessors(null, null); + } + } + + [Test] + public async Task DisposedRegion_RejectsFurtherWrites() { + var originalError = Error; + int cursorLine = 0; + RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); + try { + Error = Utilities.GetWriter(out _); + var region = new TransientConsoleRegion(); + region.Dispose(); + + await Assert.That(() => region.Render($"Loading")).Throws(); + } finally { + Error = originalError; + RenderingExtensions.ConfigureCursorAccessors(null, null); + } + } + + private static int CountOccurrences(string value, string needle) { + int count = 0; + int index = 0; + while ((index = value.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) { + count++; + index += needle.Length; + } + return count; + } +} diff --git a/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs b/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs index 6c32090..60cd104 100644 --- a/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs +++ b/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs @@ -66,6 +66,17 @@ public PrettyConsoleInterpolatedStringHandler(int literalLength, int formattedCo : this(literalLength, formattedCount, pipe, provider: null, out shouldAppend) { } + /// + /// Creates a new handler that writes to the output pipe owned by . + /// + /// Estimated literal length supplied by the compiler. + /// Formatted item count supplied by the compiler. + /// The transient region whose pipe should receive the output. + /// Always ; reserved for future short-circuiting. + public PrettyConsoleInterpolatedStringHandler(int literalLength, int formattedCount, TransientConsoleRegion region, out bool shouldAppend) + : this(literalLength, formattedCount, region.Pipe, provider: null, out shouldAppend) { + } + /// /// Creates a new handler that writes to using for formatting. /// @@ -525,4 +536,4 @@ public void Flush(bool resetColors = true) { BufferPool.Return(_buffer, false); _flushed = true; } -} \ No newline at end of file +} diff --git a/PrettyConsole/TransientConsoleRegion.cs b/PrettyConsole/TransientConsoleRegion.cs new file mode 100644 index 0000000..64626bb --- /dev/null +++ b/PrettyConsole/TransientConsoleRegion.cs @@ -0,0 +1,154 @@ +using System.Runtime.InteropServices; + +namespace PrettyConsole; + +/// +/// Owns a transient console region on a single output pipe and coordinates it with durable writes. +/// +public sealed class TransientConsoleRegion : IDisposable { + private const int InitialSnapshotCapacity = 256; + + private readonly Lock _lock = new(); + private bool _disposed; + private bool _isVisible; + private readonly List _snapshot = new(InitialSnapshotCapacity); + + /// + /// The output pipe used for both transient and durable output. + /// + public OutputPipe Pipe { get; } + + /// + /// Whether this region currently has retained transient content. + /// + public bool IsActive => _snapshot.Count > 0; + + /// + /// The number of lines occupied by the retained transient content. + /// + public int OccupiedLines { get; private set; } + + /// + /// Creates a new region bound to . + /// + public TransientConsoleRegion(OutputPipe pipe = OutputPipe.Error) { + Pipe = pipe; + } + + /// + /// Writes durable output to the region pipe. + /// + public void Write([InterpolatedStringHandlerArgument("")] ref PrettyConsoleInterpolatedStringHandler handler) { + ThrowIfDisposed(); + + lock (_lock) { + if (_isVisible) { + ClearVisibleOnly(); + } + + handler.Flush(); + } + } + + /// + /// Writes durable output followed by a newline and restores the transient region afterwards. + /// + public void WriteLine([InterpolatedStringHandlerArgument("")] ref PrettyConsoleInterpolatedStringHandler handler) { + ThrowIfDisposed(); + + lock (_lock) { + bool shouldRestore = _snapshot.Count != 0; + + if (_isVisible) { + ClearVisibleOnly(); + } + + handler.AppendNewLine(); + handler.Flush(); + + if (shouldRestore) { + WriteSnapshot(); + } + } + } + + /// + /// Replaces the transient region contents with the rendered handler output. + /// + public void Render([InterpolatedStringHandlerArgument("")] ref PrettyConsoleInterpolatedStringHandler handler) { + ThrowIfDisposed(); + + lock (_lock) { + + if (_isVisible) { + ClearVisibleOnly(); + } + + handler.ResetColors(); + var written = handler.WrittenSpan; + if (written.Length == 0) { + _snapshot.Clear(); + OccupiedLines = 0; + handler.FlushWithoutWrite(); + return; + } + + var lengthDelta = _snapshot.Count - written.Length; + _snapshot.EnsureCapacity(written.Length); + if (lengthDelta > 0) CollectionsMarshal.AsSpan(_snapshot).Slice(written.Length, lengthDelta).Clear(); + CollectionsMarshal.SetCount(_snapshot, written.Length); + written.CopyTo(CollectionsMarshal.AsSpan(_snapshot)); + OccupiedLines = CountLines(CollectionsMarshal.AsSpan(_snapshot)); + handler.FlushWithoutWrite(); + WriteSnapshot(); + } + } + + /// + /// Clears the transient region and forgets any retained snapshot. + /// + public void Clear() { + lock (_lock) { + if (_disposed) { + return; + } + + if (_isVisible) { + ClearVisibleOnly(); + } + + _snapshot.Clear(); + OccupiedLines = 0; + } + } + + /// + public void Dispose() { + Clear(); + _disposed = true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int CountLines(ReadOnlySpan buffer) => buffer.Count(Environment.NewLine) + 1; + + private void ClearVisibleOnly() { + Console.ClearNextLines(OccupiedLines, Pipe); + _isVisible = false; + } + + private void WriteSnapshot() { + if (_snapshot.Count == 0) { + return; + } + + int currentLine = Console.GetCurrentLine(); + ConsoleContext.GetPipeTarget(Pipe).Write(CollectionsMarshal.AsSpan(_snapshot)); + Console.GoToLine(currentLine); + _isVisible = true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ThrowIfDisposed() { + ObjectDisposedException.ThrowIf(_disposed, this); + } +} From 5a22dae77e1f99ef58ad7426a67531172d775840 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Thu, 26 Mar 2026 19:05:51 +0200 Subject: [PATCH 02/16] Improve implementation and add progress bar --- .../TransientConsoleRegionTests.cs | 64 +++++++++++++++ PrettyConsole/ProgressBar.cs | 36 +++++++-- PrettyConsole/TransientConsoleRegion.cs | 79 +++++++++++++------ 3 files changed, 149 insertions(+), 30 deletions(-) diff --git a/PrettyConsole.UnitTests/TransientConsoleRegionTests.cs b/PrettyConsole.UnitTests/TransientConsoleRegionTests.cs index 0844222..466d767 100644 --- a/PrettyConsole.UnitTests/TransientConsoleRegionTests.cs +++ b/PrettyConsole.UnitTests/TransientConsoleRegionTests.cs @@ -70,6 +70,70 @@ public async Task Write_WhileActive_WritesDurableOutputWithoutRedrawingImmediate } } + [Test] + public async Task Render_SameVisibleSnapshot_DoesNotRewriteOutput() { + var originalError = Error; + int cursorLine = 0; + RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); + try { + Error = Utilities.GetWriter(out var errorWriter); + using var region = new TransientConsoleRegion(); + + region.Render($"Loading"); + errorWriter.ToStringAndFlush(); + + region.Render($"Loading"); + + await Assert.That(errorWriter.ToString()).IsEqualTo(string.Empty); + } finally { + Error = originalError; + RenderingExtensions.ConfigureCursorAccessors(null, null); + } + } + + [Test] + public async Task RenderProgress_WithFactory_SameLine_WritesHeaderAndBar() { + var originalError = Error; + int cursorLine = 0; + RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); + try { + Error = Utilities.GetWriter(out var errorWriter); + using var region = new TransientConsoleRegion(); + + region.RenderProgress(40, (builder, out handler) => handler = builder.Build(OutputPipe.Error, $"hdr"), sameLine: true, progressColor: Cyan); + + var output = Utilities.StripAnsiSequences(errorWriter.ToString()); + await Assert.That(output).Contains("hdr"); + await Assert.That(output).Contains("["); + await Assert.That(output).Contains("40%"); + } finally { + Error = originalError; + RenderingExtensions.ConfigureCursorAccessors(null, null); + } + } + + [Test] + public async Task RenderProgress_WithFactory_TwoLines_WritesHeaderAboveBar() { + var originalError = Error; + int cursorLine = 0; + RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); + try { + Error = Utilities.GetWriter(out var errorWriter); + using var region = new TransientConsoleRegion(); + + region.RenderProgress(55, (builder, out handler) => handler = builder.Build(OutputPipe.Error, $"status"), sameLine: false, progressColor: Cyan); + + var output = Utilities.StripAnsiSequences(errorWriter.ToString()); + await Assert.That(output).Contains("status"); + await Assert.That(output).Contains(Environment.NewLine); + await Assert.That(output).Contains("55%"); + await Assert.That(region.OccupiedLines).IsEqualTo(2); + } finally { + Error = originalError; + RenderingExtensions.ConfigureCursorAccessors(null, null); + } + } + [Test] public async Task Clear_RemovesSnapshotAndMarksRegionInactive() { var originalError = Error; diff --git a/PrettyConsole/ProgressBar.cs b/PrettyConsole/ProgressBar.cs index af20bb0..2ca146a 100755 --- a/PrettyConsole/ProgressBar.cs +++ b/PrettyConsole/ProgressBar.cs @@ -151,9 +151,20 @@ public static void Render(OutputPipe pipe, double percentage, ConsoleColor progr /// Optional total line length (including brackets and percentage). When provided, the rendered output will not exceed this width unless the decorations already require more characters. public static void Render(OutputPipe pipe, int percentage, ConsoleColor progressColor, char progressChar = DefaultProgressChar, int? maxLineWidth = null) { Console.ResetColor(); + var handler = new PrettyConsoleInterpolatedStringHandler(pipe); + AppendTo(ref handler, percentage, progressColor, Console.CursorLeft, progressChar, maxLineWidth); + handler.Flush(); + } + internal static void AppendTo( + ref PrettyConsoleInterpolatedStringHandler handler, + int percentage, + ConsoleColor progressColor, + int cursorLeft, + char progressChar = DefaultProgressChar, + int? maxLineWidth = null) { int p = Math.Clamp(percentage, 0, 100); - int bufferWidth = Math.Max(0, ConsoleContext.GetWidthOrDefault() - Console.CursorLeft); + int bufferWidth = Math.Max(0, ConsoleContext.GetWidthOrDefault() - cursorLeft); const int bracketsAndSpacing = 3; // '[' + ']' + ' ' const int percentageWidth = 3; // numeric portion width @@ -168,12 +179,23 @@ public static void Render(OutputPipe pipe, int percentage, ConsoleColor progress int barLength = Math.Max(0, constrainedWidth - decorationWidth); int filled = Math.Min((int)(barLength * p * 0.01), barLength); - Span s = filled > 0 - ? stackalloc char[filled] - : Span.Empty; - s.Fill(progressChar); + Span progress = filled > 0 + ? stackalloc char[filled] + : Span.Empty; + progress.Fill(progressChar); int remaining = barLength - filled; - Console.WriteInterpolated(pipe, $"[{progressColor}{s}{ConsoleColor.DefaultForeground}{new WhiteSpace(remaining)}] {p,3}%"); + handler.AppendLiteral("["); + handler.AppendFormatted(progressColor); + if (filled > 0) { + handler.AppendFormatted(progress); + } + handler.AppendFormatted(ConsoleColor.DefaultForeground); + if (remaining > 0) { + handler.AppendFormatted(new WhiteSpace(remaining)); + } + handler.AppendLiteral("] "); + handler.AppendFormatted(p, 3); + handler.AppendFormatted('%'); } -} \ No newline at end of file +} diff --git a/PrettyConsole/TransientConsoleRegion.cs b/PrettyConsole/TransientConsoleRegion.cs index 64626bb..548ec18 100644 --- a/PrettyConsole/TransientConsoleRegion.cs +++ b/PrettyConsole/TransientConsoleRegion.cs @@ -9,7 +9,7 @@ public sealed class TransientConsoleRegion : IDisposable { private const int InitialSnapshotCapacity = 256; private readonly Lock _lock = new(); - private bool _disposed; + private volatile bool _disposed; private bool _isVisible; private readonly List _snapshot = new(InitialSnapshotCapacity); @@ -39,9 +39,8 @@ public TransientConsoleRegion(OutputPipe pipe = OutputPipe.Error) { /// Writes durable output to the region pipe. /// public void Write([InterpolatedStringHandlerArgument("")] ref PrettyConsoleInterpolatedStringHandler handler) { - ThrowIfDisposed(); - lock (_lock) { + ObjectDisposedException.ThrowIf(_disposed, this); if (_isVisible) { ClearVisibleOnly(); } @@ -54,9 +53,8 @@ public void Write([InterpolatedStringHandlerArgument("")] ref PrettyConsoleInter /// Writes durable output followed by a newline and restores the transient region afterwards. /// public void WriteLine([InterpolatedStringHandlerArgument("")] ref PrettyConsoleInterpolatedStringHandler handler) { - ThrowIfDisposed(); - lock (_lock) { + ObjectDisposedException.ThrowIf(_disposed, this); bool shouldRestore = _snapshot.Count != 0; if (_isVisible) { @@ -76,16 +74,20 @@ public void WriteLine([InterpolatedStringHandlerArgument("")] ref PrettyConsoleI /// Replaces the transient region contents with the rendered handler output. /// public void Render([InterpolatedStringHandlerArgument("")] ref PrettyConsoleInterpolatedStringHandler handler) { - ThrowIfDisposed(); - lock (_lock) { + ObjectDisposedException.ThrowIf(_disposed, this); + handler.ResetColors(); + var written = handler.WrittenSpan; + + if (_isVisible && CollectionsMarshal.AsSpan(_snapshot).SequenceEqual(written)) { + handler.FlushWithoutWrite(); + return; + } if (_isVisible) { ClearVisibleOnly(); } - handler.ResetColors(); - var written = handler.WrittenSpan; if (written.Length == 0) { _snapshot.Clear(); OccupiedLines = 0; @@ -93,17 +95,45 @@ public void Render([InterpolatedStringHandlerArgument("")] ref PrettyConsoleInte return; } - var lengthDelta = _snapshot.Count - written.Length; _snapshot.EnsureCapacity(written.Length); - if (lengthDelta > 0) CollectionsMarshal.AsSpan(_snapshot).Slice(written.Length, lengthDelta).Clear(); CollectionsMarshal.SetCount(_snapshot, written.Length); - written.CopyTo(CollectionsMarshal.AsSpan(_snapshot)); - OccupiedLines = CountLines(CollectionsMarshal.AsSpan(_snapshot)); + var snapshot = CollectionsMarshal.AsSpan(_snapshot); + written.CopyTo(snapshot); + OccupiedLines = snapshot.Count(Environment.NewLine) + 1; handler.FlushWithoutWrite(); WriteSnapshot(); } } + /// + /// Renders a progress bar snapshot into this region, optionally prefixed with handler-built content. + /// + public void RenderProgress( + double percentage, + PrettyConsoleInterpolatedStringHandlerFactory? factory = null, + bool sameLine = true, + ConsoleColor? progressColor = null, + char progressChar = ProgressBar.DefaultProgressChar, + int? maxLineWidth = null) { + var handler = new PrettyConsoleInterpolatedStringHandler(Pipe); + int cursorLeft = 0; + + if (factory is not null) { + factory(PrettyConsoleInterpolatedStringHandlerBuilder.Singleton, out var headerHandler); + handler.AppendInline(Pipe, ref headerHandler); + + if (sameLine) { + handler.AppendFormatted(new WhiteSpace(1)); + cursorLeft = handler.CharsWritten; + } else { + handler.AppendNewLine(); + } + } + + ProgressBar.AppendTo(ref handler, (int)percentage, progressColor ?? ConsoleColor.DefaultForeground, cursorLeft, progressChar, maxLineWidth); + Render(ref handler); + } + /// /// Clears the transient region and forgets any retained snapshot. /// @@ -124,12 +154,20 @@ public void Clear() { /// public void Dispose() { - Clear(); - _disposed = true; - } + lock (_lock) { + if (_disposed) { + return; + } + + if (_isVisible) { + ClearVisibleOnly(); + } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int CountLines(ReadOnlySpan buffer) => buffer.Count(Environment.NewLine) + 1; + _snapshot.Clear(); + OccupiedLines = 0; + _disposed = true; + } + } private void ClearVisibleOnly() { Console.ClearNextLines(OccupiedLines, Pipe); @@ -146,9 +184,4 @@ private void WriteSnapshot() { Console.GoToLine(currentLine); _isVisible = true; } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void ThrowIfDisposed() { - ObjectDisposedException.ThrowIf(_disposed, this); - } } From 049166d96745655610f5204be7e4e54e51251d33 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Thu, 26 Mar 2026 19:39:43 +0200 Subject: [PATCH 03/16] Update name --- ...egionTests.cs => LiveConsoleRegionTests.cs} | 18 +++++++++--------- ...ntConsoleRegion.cs => LiveConsoleRegion.cs} | 4 ++-- .../PrettyConsoleInterpolatedStringHandler.cs | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) rename PrettyConsole.UnitTests/{TransientConsoleRegionTests.cs => LiveConsoleRegionTests.cs} (92%) rename PrettyConsole/{TransientConsoleRegion.cs => LiveConsoleRegion.cs} (97%) diff --git a/PrettyConsole.UnitTests/TransientConsoleRegionTests.cs b/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs similarity index 92% rename from PrettyConsole.UnitTests/TransientConsoleRegionTests.cs rename to PrettyConsole.UnitTests/LiveConsoleRegionTests.cs index 466d767..c0ee7ab 100644 --- a/PrettyConsole.UnitTests/TransientConsoleRegionTests.cs +++ b/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs @@ -1,7 +1,7 @@ namespace PrettyConsole.UnitTests; [SkipWhenConsoleUnavailable] -public class TransientConsoleRegionTests { +public class LiveConsoleRegionTests { [Test] public async Task Render_WritesSnapshotAndMarksRegionActive() { var originalError = Error; @@ -9,7 +9,7 @@ public async Task Render_WritesSnapshotAndMarksRegionActive() { RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); try { Error = Utilities.GetWriter(out var errorWriter); - using var region = new TransientConsoleRegion(); + using var region = new LiveConsoleRegion(); region.Render($"Working"); @@ -29,7 +29,7 @@ public async Task WriteLine_WhileActive_WritesDurableOutputAndRestoresRegion() { RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); try { Error = Utilities.GetWriter(out var errorWriter); - using var region = new TransientConsoleRegion(); + using var region = new LiveConsoleRegion(); region.Render($"Loading"); errorWriter.ToStringAndFlush(); @@ -53,7 +53,7 @@ public async Task Write_WhileActive_WritesDurableOutputWithoutRedrawingImmediate RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); try { Error = Utilities.GetWriter(out var errorWriter); - using var region = new TransientConsoleRegion(); + using var region = new LiveConsoleRegion(); region.Render($"Loading"); errorWriter.ToStringAndFlush(); @@ -77,7 +77,7 @@ public async Task Render_SameVisibleSnapshot_DoesNotRewriteOutput() { RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); try { Error = Utilities.GetWriter(out var errorWriter); - using var region = new TransientConsoleRegion(); + using var region = new LiveConsoleRegion(); region.Render($"Loading"); errorWriter.ToStringAndFlush(); @@ -98,7 +98,7 @@ public async Task RenderProgress_WithFactory_SameLine_WritesHeaderAndBar() { RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); try { Error = Utilities.GetWriter(out var errorWriter); - using var region = new TransientConsoleRegion(); + using var region = new LiveConsoleRegion(); region.RenderProgress(40, (builder, out handler) => handler = builder.Build(OutputPipe.Error, $"hdr"), sameLine: true, progressColor: Cyan); @@ -119,7 +119,7 @@ public async Task RenderProgress_WithFactory_TwoLines_WritesHeaderAboveBar() { RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); try { Error = Utilities.GetWriter(out var errorWriter); - using var region = new TransientConsoleRegion(); + using var region = new LiveConsoleRegion(); region.RenderProgress(55, (builder, out handler) => handler = builder.Build(OutputPipe.Error, $"status"), sameLine: false, progressColor: Cyan); @@ -141,7 +141,7 @@ public async Task Clear_RemovesSnapshotAndMarksRegionInactive() { RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); try { Error = Utilities.GetWriter(out var errorWriter); - using var region = new TransientConsoleRegion(); + using var region = new LiveConsoleRegion(); region.Render($"Loading"); errorWriter.ToStringAndFlush(); @@ -164,7 +164,7 @@ public async Task DisposedRegion_RejectsFurtherWrites() { RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); try { Error = Utilities.GetWriter(out _); - var region = new TransientConsoleRegion(); + var region = new LiveConsoleRegion(); region.Dispose(); await Assert.That(() => region.Render($"Loading")).Throws(); diff --git a/PrettyConsole/TransientConsoleRegion.cs b/PrettyConsole/LiveConsoleRegion.cs similarity index 97% rename from PrettyConsole/TransientConsoleRegion.cs rename to PrettyConsole/LiveConsoleRegion.cs index 548ec18..5b54082 100644 --- a/PrettyConsole/TransientConsoleRegion.cs +++ b/PrettyConsole/LiveConsoleRegion.cs @@ -5,7 +5,7 @@ namespace PrettyConsole; /// /// Owns a transient console region on a single output pipe and coordinates it with durable writes. /// -public sealed class TransientConsoleRegion : IDisposable { +public sealed class LiveConsoleRegion : IDisposable { private const int InitialSnapshotCapacity = 256; private readonly Lock _lock = new(); @@ -31,7 +31,7 @@ public sealed class TransientConsoleRegion : IDisposable { /// /// Creates a new region bound to . /// - public TransientConsoleRegion(OutputPipe pipe = OutputPipe.Error) { + public LiveConsoleRegion(OutputPipe pipe = OutputPipe.Error) { Pipe = pipe; } diff --git a/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs b/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs index 60cd104..b8d16f7 100644 --- a/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs +++ b/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs @@ -73,7 +73,7 @@ public PrettyConsoleInterpolatedStringHandler(int literalLength, int formattedCo /// Formatted item count supplied by the compiler. /// The transient region whose pipe should receive the output. /// Always ; reserved for future short-circuiting. - public PrettyConsoleInterpolatedStringHandler(int literalLength, int formattedCount, TransientConsoleRegion region, out bool shouldAppend) + public PrettyConsoleInterpolatedStringHandler(int literalLength, int formattedCount, LiveConsoleRegion region, out bool shouldAppend) : this(literalLength, formattedCount, region.Pipe, provider: null, out shouldAppend) { } From 233965a2c2cbeff679c642c9a519553506147ad9 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 27 Mar 2026 09:10:37 +0300 Subject: [PATCH 04/16] finalize implementation --- .editorconfig | 1 + .../LiveConsoleRegionTests.cs | 71 ++++++++++++------- PrettyConsole/LiveConsoleRegion.cs | 30 +++----- 3 files changed, 55 insertions(+), 47 deletions(-) diff --git a/.editorconfig b/.editorconfig index 30024f0..f8097e6 100755 --- a/.editorconfig +++ b/.editorconfig @@ -85,6 +85,7 @@ dotnet_diagnostic.IDE0301.severity = none # simplify collection initialization dotnet_diagnostic.IDE0053.severity = none # expression body lambda dotnet_diagnostic.IDE0046.severity = none # simplify if(s) - conditional operator dotnet_diagnostic.IDE0305.severity = none # [, ...] instead of .ToArray() +dotnet_diagnostic.IDE0032.severity = none # use auto property # namespace declaration diff --git a/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs b/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs index c0ee7ab..5712a38 100644 --- a/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs +++ b/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs @@ -34,10 +34,10 @@ public async Task WriteLine_WhileActive_WritesDurableOutputAndRestoresRegion() { region.Render($"Loading"); errorWriter.ToStringAndFlush(); - region.WriteLine($"Updated serde"); + region.WriteLine($"Updated package-a"); var output = Utilities.StripAnsiSequences(errorWriter.ToString()); - await Assert.That(output).Contains("Updated serde"); + await Assert.That(output).Contains("Updated package-a"); await Assert.That(CountOccurrences(output, "Loading")).IsEqualTo(1); await Assert.That(region.IsActive).IsTrue(); } finally { @@ -46,30 +46,6 @@ public async Task WriteLine_WhileActive_WritesDurableOutputAndRestoresRegion() { } } - [Test] - public async Task Write_WhileActive_WritesDurableOutputWithoutRedrawingImmediately() { - var originalError = Error; - int cursorLine = 0; - RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); - try { - Error = Utilities.GetWriter(out var errorWriter); - using var region = new LiveConsoleRegion(); - - region.Render($"Loading"); - errorWriter.ToStringAndFlush(); - - region.Write($"step "); - - var output = Utilities.StripAnsiSequences(errorWriter.ToString()); - await Assert.That(output).Contains("step "); - await Assert.That(output).DoesNotContain("Loading"); - await Assert.That(region.IsActive).IsTrue(); - } finally { - Error = originalError; - RenderingExtensions.ConfigureCursorAccessors(null, null); - } - } - [Test] public async Task Render_SameVisibleSnapshot_DoesNotRewriteOutput() { var originalError = Error; @@ -134,6 +110,49 @@ public async Task RenderProgress_WithFactory_TwoLines_WritesHeaderAboveBar() { } } + [Test] + public async Task Render_UsesCurrentWriter_WhenPipeTargetChangesAfterCreation() { + var originalError = Error; + int cursorLine = 0; + RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); + try { + using var region = new LiveConsoleRegion(); + + Error = Utilities.GetWriter(out var firstWriter); + region.Render($"First"); + firstWriter.ToStringAndFlush(); + + Error = Utilities.GetWriter(out var secondWriter); + region.WriteLine($"Next"); + + var output = Utilities.StripAnsiSequences(secondWriter.ToString()); + await Assert.That(output).Contains("Next"); + await Assert.That(output).Contains("First"); + } finally { + Error = originalError; + RenderingExtensions.ConfigureCursorAccessors(null, null); + } + } + + [Test] + public async Task Render_WithLoneLineFeeds_CountsOccupiedLinesCorrectly() { + var originalError = Error; + int cursorLine = 0; + RenderingExtensions.ConfigureCursorAccessors(() => cursorLine, (_, line) => cursorLine = line); + try { + Error = Utilities.GetWriter(out var errorWriter); + using var region = new LiveConsoleRegion(); + + region.Render($"Line1\nLine2\nLine3"); + + await Assert.That(region.OccupiedLines).IsEqualTo(3); + await Assert.That(Utilities.StripAnsiSequences(errorWriter.ToString())).Contains("Line1"); + } finally { + Error = originalError; + RenderingExtensions.ConfigureCursorAccessors(null, null); + } + } + [Test] public async Task Clear_RemovesSnapshotAndMarksRegionInactive() { var originalError = Error; diff --git a/PrettyConsole/LiveConsoleRegion.cs b/PrettyConsole/LiveConsoleRegion.cs index 5b54082..648c125 100644 --- a/PrettyConsole/LiveConsoleRegion.cs +++ b/PrettyConsole/LiveConsoleRegion.cs @@ -13,15 +13,17 @@ public sealed class LiveConsoleRegion : IDisposable { private bool _isVisible; private readonly List _snapshot = new(InitialSnapshotCapacity); + private readonly OutputPipe _pipe; + /// /// The output pipe used for both transient and durable output. /// - public OutputPipe Pipe { get; } + public OutputPipe Pipe => _pipe; /// /// Whether this region currently has retained transient content. /// - public bool IsActive => _snapshot.Count > 0; + public bool IsActive => _snapshot.Count != 0; /// /// The number of lines occupied by the retained transient content. @@ -32,21 +34,7 @@ public sealed class LiveConsoleRegion : IDisposable { /// Creates a new region bound to . /// public LiveConsoleRegion(OutputPipe pipe = OutputPipe.Error) { - Pipe = pipe; - } - - /// - /// Writes durable output to the region pipe. - /// - public void Write([InterpolatedStringHandlerArgument("")] ref PrettyConsoleInterpolatedStringHandler handler) { - lock (_lock) { - ObjectDisposedException.ThrowIf(_disposed, this); - if (_isVisible) { - ClearVisibleOnly(); - } - - handler.Flush(); - } + _pipe = pipe; } /// @@ -115,12 +103,12 @@ public void RenderProgress( ConsoleColor? progressColor = null, char progressChar = ProgressBar.DefaultProgressChar, int? maxLineWidth = null) { - var handler = new PrettyConsoleInterpolatedStringHandler(Pipe); + var handler = new PrettyConsoleInterpolatedStringHandler(_pipe); int cursorLeft = 0; if (factory is not null) { factory(PrettyConsoleInterpolatedStringHandlerBuilder.Singleton, out var headerHandler); - handler.AppendInline(Pipe, ref headerHandler); + handler.AppendInline(_pipe, ref headerHandler); if (sameLine) { handler.AppendFormatted(new WhiteSpace(1)); @@ -170,7 +158,7 @@ public void Dispose() { } private void ClearVisibleOnly() { - Console.ClearNextLines(OccupiedLines, Pipe); + Console.ClearNextLines(OccupiedLines, _pipe); _isVisible = false; } @@ -180,7 +168,7 @@ private void WriteSnapshot() { } int currentLine = Console.GetCurrentLine(); - ConsoleContext.GetPipeTarget(Pipe).Write(CollectionsMarshal.AsSpan(_snapshot)); + ConsoleContext.GetPipeTarget(_pipe).Write(CollectionsMarshal.AsSpan(_snapshot)); Console.GoToLine(currentLine); _isVisible = true; } From a263726af2dcd41eddaa269fa978d92d90428696 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 27 Mar 2026 14:05:09 +0300 Subject: [PATCH 05/16] update docs --- .agents/skills/pretty-console-expert/SKILL.md | 15 ++++++++++- AGENTS.md | 27 +++++++++---------- PrettyConsole/PrettyConsole.csproj | 7 ++--- README.md | 23 +++++++++++++++- Versions.md | 8 +++++- 5 files changed, 60 insertions(+), 20 deletions(-) diff --git a/.agents/skills/pretty-console-expert/SKILL.md b/.agents/skills/pretty-console-expert/SKILL.md index 9d95512..d925c8b 100644 --- a/.agents/skills/pretty-console-expert/SKILL.md +++ b/.agents/skills/pretty-console-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: pretty-console-expert -description: Expert workflow for using PrettyConsole correctly and efficiently in C# console apps. Use when tasks involve console styling, colored output, regular prints, prompts, typed input parsing, confirmation prompts, menu/table rendering, overwrite-based rendering, progress bars, spinners, OutputPipe routing, or migration from Spectre.Console/manual ANSI/older PrettyConsole APIs. +description: Expert workflow for using PrettyConsole correctly and efficiently in C# console apps. Use when tasks involve console styling, colored output, regular prints, prompts, typed input parsing, confirmation prompts, menu/table rendering, overwrite-based rendering, live console regions, progress bars, spinners, OutputPipe routing, or migration from Spectre.Console/manual ANSI/older PrettyConsole APIs. --- # PrettyConsole Expert @@ -22,6 +22,7 @@ using static System.Console; // optional - Styled output: `Console.WriteInterpolated`, `Console.WriteLineInterpolated`. - Inputs/prompts: `Console.TryReadLine`, `Console.ReadLine`, `Console.Confirm`, `Console.RequestAnyInput`. - Dynamic rendering and line control: `Console.Overwrite`, `Console.ClearNextLines`, `Console.SkipLines`, `Console.NewLine`. +- Retained live status UI on one pipe: `LiveConsoleRegion.WriteLine`, `LiveConsoleRegion.Render`, `LiveConsoleRegion.RenderProgress`, `LiveConsoleRegion.Clear`. - Progress UI: `ProgressBar.Update`, `ProgressBar.Render`, `Spinner.RunAsync`. - Menus/tables: `Console.Selection`, `Console.MultiSelection`, `Console.TreeMenu`, `Console.Table`. - Low-level override only: use `Console.Write(...)` / `Console.WriteLine(...)` span+`ISpanFormattable` overloads only when you intentionally bypass the handler for a custom formatting pipeline. @@ -29,6 +30,7 @@ using static System.Console; // optional 4. Route output deliberately. - Keep normal prompts, menus, tables, durable user-facing output, and machine-readable non-error output on `OutputPipe.Out` unless there is a specific reason not to. - Use `OutputPipe.Error` for transient live UI and for actual errors/diagnostics/warnings so stdout stays pipe-friendly and error output remains distinguishable. +- `LiveConsoleRegion` should usually live on `OutputPipe.Error` in interactive CLIs. Keep the durable lines that must coordinate with it flowing through the region instance instead of writing directly to the same pipe elsewhere. - Do not bounce a single interaction between `Out` and `Error` unless you intentionally want that split; mixed-pipe prompts and retry messages are usually awkward in consumer CLIs. ## Handler Special Formats @@ -48,12 +50,14 @@ using static System.Console; // optional - Keep ANSI/decorations inside interpolation holes (for example, `$"{Markup.Bold}..."`) instead of literal escape codes inside string literals. - Route transient UI (spinner/progress/overwrite loops) to `OutputPipe.Error` to keep stdout pipe-friendly, and use `OutputPipe.Error` for genuine errors/diagnostics. Keep ordinary non-error interaction flow on `OutputPipe.Out`. - Spinner/progress/overwrite output is caller-owned after rendering completes. Explicitly remove it with `Console.ClearNextLines(totalLines, pipe)` or intentionally keep the region with `Console.SkipLines(totalLines)`. +- `LiveConsoleRegion` is the right primitive when durable line output and transient status must interleave over time. It is line-oriented: use `WriteLine`, not inline writes, for cooperating durable output above the retained region. - Only use the bounded `Channel` snapshot pattern when multiple producers must update the same live region at high frequency. For single-producer or modest-rate updates, keep the rendering loop simple. ## Practical Patterns - For wizard-like flows, wrap `Console.Selection(...)` / `Console.MultiSelection(...)` in retrying `Console.Overwrite(...)` loops so each step reuses one screen region instead of scrolling. Keep the whole prompt/retry exchange on `OutputPipe.Out` unless the message is genuinely diagnostic. - Prefer `Console.Overwrite(state, static ...)` for fixed-height live regions such as `status + progress`; it avoids closure captures and keeps the rendered surface explicit through `lines`. +- Prefer `LiveConsoleRegion` when you need durable status lines streaming above a retained transient line on the same pipe. - For dynamic spinner/progress headers tied to concurrent work, keep the mutable step/progress state outside the renderer and read it with `Volatile.Read` / `Interlocked` inside the handler factory. - If a live region should disappear after completion, pair the last render with an explicit `ClearNextLines(...)`. If it should remain visible as completed output, advance past it with `SkipLines(...)`. @@ -70,6 +74,7 @@ using static System.Console; // optional - Use `Spinner`, not `IndeterminateProgressBar`. - Use `Pattern`, not `AnimationSequence`. - Use `ProgressBar.Render(...)`, not `ProgressBar.WriteProgressBar(...)`. +- Use `LiveConsoleRegion` for retained live regions; do not approximate that behavior with ad-hoc `Overwrite` loops when durable writes must keep streaming around the live output. - Use `ConsoleContext`, not `PrettyConsoleExtensions`. - Use `ConsoleColor` helpers/tuples (for example `ConsoleColor.Red / ConsoleColor.White`), not removed `ColoredOutput`/`Color` types. - Use `Console.NewLine(pipe)` when you only need a newline or blank line; do not use `WriteLineInterpolated` with empty/reset-only payloads just to move the cursor. @@ -101,6 +106,14 @@ Console.ClearNextLines(1, OutputPipe.Error); // or Console.SkipLines(1) to keep var bar = new ProgressBar { ProgressColor = ConsoleColor.Green }; bar.Update(65, "Downloading", sameLine: true); ProgressBar.Render(OutputPipe.Error, 65, ConsoleColor.Green); + +// Retained live region +using var live = new LiveConsoleRegion(OutputPipe.Error); +live.Render($"Resolving graph"); +live.WriteLine($"Updated package-a"); +live.RenderProgress(65, (builder, out handler) => + handler = builder.Build(OutputPipe.Error, $"Compiling")); +live.Clear(); ``` ## Reference File diff --git a/AGENTS.md b/AGENTS.md index 8c756ec..4722782 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,13 @@ Repository: PrettyConsole Summary -- PrettyConsole is a high-performance, allocation-conscious extension layer over System.Console (implemented via C# extension members) that provides structured colored output, input helpers, rendering controls, menus, and progress bars. It targets net10.0, is trimming/AOT ready, and ships SourceLink metadata for debugging. +- PrettyConsole is a high-performance, allocation-conscious extension layer over System.Console (implemented via C# extension members) that provides structured colored output, input helpers, rendering controls, menus, progress bars, and live console regions. It targets net10.0, is trimming/AOT ready, and ships SourceLink metadata for debugging. - Solution layout: - PrettyConsole/ — main library - PrettyConsole.Tests/ — interactive/demo runner (manually selects visual feature demos) - - PrettyConsole.Tests.Unit/ — xUnit v3 unit tests using Microsoft Testing Platform + - PrettyConsole.UnitTests/ — TUnit-based automated tests run with `dotnet run` - Examples/ — standalone `.cs` sample apps plus `assets/` previews; documented in `Examples/README.md` and excluded from automated builds/tests -- v5.4.0 (current) renames `IndeterminateProgressBar` to `Spinner` (and `AnimationSequence` to `Pattern`), triggers the line reset at the start of each spinner frame, gives all `RunAsync` overloads default cancellation tokens, and renames `ProgressBar.WriteProgressBar` to `Render` while adding handler-factory overloads and switching header parameters to `string`. It still passes handlers by `ref`, adds `AppendInline`, and introduces the ctor that takes only `OutputPipe` + optional `IFormatProvider`; `SkipLines` advances the cursor while keeping overwritten UIs; `Confirm(trueValues, ref handler, bool emptyIsTrue = true)` has the boolean last; spinner header factories use `PrettyConsoleInterpolatedStringHandlerFactory` with the singleton builder; `AnsiColors` is public. v5.2.0 rewrote the handler to buffer before writing and added `WhiteSpace`; v5.1.0 renamed `PrettyConsoleExtensions` to `ConsoleContext`, added `Console.WriteWhiteSpaces(length, pipe)`, and made `Out`/`Error`/`In` settable; v5.0.0 removed the legacy `ColoredOutput`/`Color` types in favor of `ConsoleColor` helpers and tuples. +- v5.5.0 (current) adds `LiveConsoleRegion`, a retained single-owner live region for Cargo-style durable status streaming plus a pinned transient line on one `OutputPipe`. It exposes line-oriented `WriteLine`, generic `Render`, region-owned `RenderProgress`, `Clear`, and `Dispose`, and the interpolated string handler now has a constructor overload that binds interpolation directly to a `LiveConsoleRegion` instance. v5.4.0 renamed `IndeterminateProgressBar` to `Spinner` (and `AnimationSequence` to `Pattern`), triggers the line reset at the start of each spinner frame, gives all `RunAsync` overloads default cancellation tokens, and renames `ProgressBar.WriteProgressBar` to `Render` while adding handler-factory overloads and switching header parameters to `string`. It still passes handlers by `ref`, adds `AppendInline`, and introduces the ctor that takes only `OutputPipe` + optional `IFormatProvider`; `SkipLines` advances the cursor while keeping overwritten UIs; `Confirm(trueValues, ref handler, bool emptyIsTrue = true)` has the boolean last; spinner header factories use `PrettyConsoleInterpolatedStringHandlerFactory` with the singleton builder; `AnsiColors` is public. v5.2.0 rewrote the handler to buffer before writing and added `WhiteSpace`; v5.1.0 renamed `PrettyConsoleExtensions` to `ConsoleContext`, added `Console.WriteWhiteSpaces(length, pipe)`, and made `Out`/`Error`/`In` settable; v5.0.0 removed the legacy `ColoredOutput`/`Color` types in favor of `ConsoleColor` helpers and tuples. Commands you’ll use often @@ -18,26 +18,22 @@ Commands you’ll use often - Build library: - dotnet build PrettyConsole/PrettyConsole.csproj - Build unit tests: - - dotnet build PrettyConsole.Tests.Unit/PrettyConsole.Tests.Unit.csproj + - dotnet build PrettyConsole.UnitTests/PrettyConsole.UnitTests.csproj - The solution using .slnx format; run it as usual but prefer to build individual projects as needed. - Format (uses the repo’s .editorconfig conventions) - Check and fix code style/formatting: - dotnet format - Run - Never run interactive/demo tests (PrettyConsole.Tests) - - Run unit tests (xUnit v3 via Microsoft Testing Platform): - - dotnet run --project PrettyConsole.Tests.Unit - - Run a single unit test: - - dotnet run --project PrettyConsole.Tests.Unit --filter-method "*UniquePartOfMethodName*" - - Examples: - - dotnet run --project PrettyConsole.Tests.Unit --filter-method "*WritesColoredLine*" + - Run unit tests: + - dotnet run --project PrettyConsole.UnitTests -- --no-progress --disable-logo - Pack - DO NOT DO THIS YOURSELF! Repo-specific agent rules and conventions - Prefer dotnet CLI for making, verifying, and running changes. - When changing a specific project, build/run just that project to validate, not the entire solution. -- For tests using Microsoft Testing Platform and/or xUnit v3, use dotnet run, never dotnet test. +- For tests in `PrettyConsole.UnitTests`, use dotnet run, never dotnet test. - Adhere to .editorconfig in the repo for style and analyzers. - If code needs to be “removed” as part of a change, do not delete files; comment out their contents so they won’t compile. - Avoid reflection/dynamic assembly loading in published library code unless explicitly requested. @@ -65,6 +61,8 @@ High-level architecture and key concepts - `ClearNextLines`, `GoToLine`, `GetCurrentLine`, and `SkipLines` coordinate bounded screen regions; `Clear` wipes the buffer when safe. `SkipLines` lets you advance the cursor to preserve overwritten UIs (progress bars, spinners) after completion. These helpers underpin progress rendering and overwrite scenarios. - Advanced outputs - `OverwriteCurrentLine`, `Overwrite`, and `Overwrite` run user actions while clearing a configurable number of lines. Set the `lines` argument to however many rows you emit during the action (e.g., the multi-progress sample uses `lines: 2`) and call `Console.ClearNextLines` once after the last overwrite to remove residual UI. `TypeWrite`/`TypeWriteLine` animate character-by-character output with adjustable delays. +- Live regions + - `LiveConsoleRegion` owns one retained live region on a single `OutputPipe` and coordinates it with durable line output on that same pipe. Use `WriteLine` for durable lines that should stream above the retained region, `Render` for arbitrary transient snapshots, `RenderProgress` as the built-in progress convenience, and `Clear`/`Dispose` to remove the region. Treat it as a cooperating-writers abstraction: output that must coordinate with the live region should flow through the region instance rather than writing directly to the same pipe behind its back. - Menus and tables - `Selection` returns a single choice or empty string on invalid input; `MultiSelection` parses space-separated indices into string arrays; `TreeMenu` renders two-level hierarchies and validates input (throwing `ArgumentException` when selections are invalid); `Table` renders headers + columns with width calculations. - Progress bars @@ -77,9 +75,9 @@ Testing structure and workflows - PrettyConsole.Tests (interactive) - `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.Render` helper. Keep these behaviours in sync with docs. +- PrettyConsole.UnitTests + - Execute with `dotnet run --project PrettyConsole.UnitTests -- --no-progress --disable-logo`. + - Coverage includes progress rendering, handler formatting behavior, and `LiveConsoleRegion` scenarios such as retained redraw, progress rendering, live-region clearing, and pipe-target changes. Keep these behaviors in sync with docs. Notes and gotchas @@ -87,4 +85,5 @@ Notes and gotchas - 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.Render` renders one-off bars without writing a trailing newline, so rely on `Console.Overwrite`/`lines` to stack multiple bars cleanly. +- `LiveConsoleRegion` is line-oriented by design: it restores after `WriteLine`, not after arbitrary inline text, and it owns only one pipe. Default to `OutputPipe.Error` for interactive status UI so stdout stays pipe-friendly. - After the final `Overwrite`/`Overwrite` call in a rendering loop, call `Console.ClearNextLines(totalLines, pipe)` once more to clear the region and prevent ghost text. diff --git a/PrettyConsole/PrettyConsole.csproj b/PrettyConsole/PrettyConsole.csproj index 9054ddb..79fcc07 100755 --- a/PrettyConsole/PrettyConsole.csproj +++ b/PrettyConsole/PrettyConsole.csproj @@ -26,7 +26,7 @@ https://github.com/dusrdev/PrettyConsole/ https://github.com/dusrdev/PrettyConsole/ git - 5.4.2 + 5.5.0 enable MIT True @@ -43,8 +43,9 @@ - - Improve perf of ReadOnlySpan based overloads of Write and WriteLine. - - Skill improvements + - Added LiveConsoleRegion for retained live output on a single OutputPipe, enabling Cargo-style durable status lines above a pinned transient region. + - LiveConsoleRegion exposes WriteLine, Render, RenderProgress, Clear, and Dispose. + - Interpolated strings now bind directly to LiveConsoleRegion so $"..." can be passed into region methods naturally. diff --git a/README.md b/README.md index ed1aff6..5d08db5 100755 --- a/README.md +++ b/README.md @@ -5,13 +5,14 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](License.txt) [![.NET](https://img.shields.io/badge/.NET-10.0-512BD4?style=flat-square)](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) -PrettyConsole is a high-performance, ultra-low-latency, allocation-free extension layer over `System.Console`. The library uses C# extension members (`extension(Console)`) so every API lights up directly on `System.Console` once `using PrettyConsole;` is in scope. It is trimming/AOT ready, preserves SourceLink metadata, and keeps the familiar console experience while adding structured rendering, menus, progress bars, and advanced input helpers. +PrettyConsole is a high-performance, ultra-low-latency, allocation-free extension layer over `System.Console`. The library uses C# extension members (`extension(Console)`) so every API lights up directly on `System.Console` once `using PrettyConsole;` is in scope. It is trimming/AOT ready, preserves SourceLink metadata, and keeps the familiar console experience while adding structured rendering, menus, progress bars, live console regions, and advanced input helpers. ## Features - 🚀 Zero-allocation interpolated string handler (`PrettyConsoleInterpolatedStringHandler`) for inline colors and formatting - 🎨 Inline color composition with `ConsoleColor` tuples and helpers (`DefaultForeground`, `DefaultBackground`, `Default`) plus `AnsiColors` utilities when you need raw ANSI sequences - 🔁 Advanced rendering primitives (`Overwrite`, `ClearNextLines`, `GoToLine`, `SkipLines`, progress bars) that respect console pipes +- 📌 `LiveConsoleRegion` for a retained live line/region that stays pinned while durable status lines stream above it on the same pipe - 🧱 Handler-aware `WhiteSpace` struct for zero-allocation padding directly inside interpolated strings - 🧰 Rich input helpers (`TryReadLine`, `Confirm`, `RequestAnyInput`) with `IParsable` and enum support - ⚙️ Low-level output escape hatches (`Console.Write/WriteLine` span and `ISpanFormattable` overloads) for advanced custom formatting pipelines @@ -232,6 +233,26 @@ await Console.TypeWriteLine("Ready.", ConsoleColor.Default); Always call `Console.ClearNextLines(totalLines, pipe)` once after the last `Overwrite` to erase the region when you are done. +### Live console regions + +`LiveConsoleRegion` owns one retained live region on a single `OutputPipe` and coordinates it with durable line output on that same pipe. This is the right fit where status lines stream normally while a pinned transient line keeps updating at the bottom. + +```csharp +using var live = new LiveConsoleRegion(OutputPipe.Error); + +live.Render($"Resolving graph"); +live.WriteLine($"Updated package-a"); +live.WriteLine($"Updated package-b"); + +live.RenderProgress(42, (builder, out handler) => + handler = builder.Build(OutputPipe.Error, $"Compiling")); + +live.Render($"Linking {elapsed:duration}"); +live.Clear(); +``` + +Use `WriteLine` for durable lines that should scroll above the retained region, `Render` for arbitrary transient snapshots, and `RenderProgress` when you want the built-in progress bar renderer inside the region. Keep all output that must coordinate with the live region flowing through that region instance. In interactive CLIs, `OutputPipe.Error` is usually the correct pipe so stdout remains machine-friendly. + ### Menus and tables ```csharp diff --git a/Versions.md b/Versions.md index 115db5f..afd5fb0 100755 --- a/Versions.md +++ b/Versions.md @@ -1,6 +1,12 @@ # Versions -# v5.4.2 +## v5.5.0 + +- Added `LiveConsoleRegion` for retained live output on a single `OutputPipe`, enabling Cargo-style durable status lines above a pinned transient region. +- `LiveConsoleRegion` exposes `WriteLine`, `Render`, `RenderProgress`, `Clear`, and `Dispose`. +- Interpolated strings now bind directly to `LiveConsoleRegion`, so `$"..."` can be passed into `WriteLine` and `Render` naturally. + +## v5.4.2 - Improve perf of `ReadOnlySpan` based overloads of `Write` and `WriteLine`. - `SKILL` improvements From 97c710c3ca40af944e9ab72835f9c36636f71d88 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 27 Mar 2026 14:30:06 +0300 Subject: [PATCH 06/16] Align color setting APIs to handler --- PrettyConsole/ProgressBar.cs | 1 - PrettyConsole/WriteExtensions.cs | 6 ++---- PrettyConsole/WriteLineExtensions.cs | 6 ++---- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/PrettyConsole/ProgressBar.cs b/PrettyConsole/ProgressBar.cs index 2ca146a..853d17f 100755 --- a/PrettyConsole/ProgressBar.cs +++ b/PrettyConsole/ProgressBar.cs @@ -150,7 +150,6 @@ public static void Render(OutputPipe pipe, double percentage, ConsoleColor progr /// The character used to render the filled portion of the bar. /// Optional total line length (including brackets and percentage). When provided, the rendered output will not exceed this width unless the decorations already require more characters. public static void Render(OutputPipe pipe, int percentage, ConsoleColor progressColor, char progressChar = DefaultProgressChar, int? maxLineWidth = null) { - Console.ResetColor(); var handler = new PrettyConsoleInterpolatedStringHandler(pipe); AppendTo(ref handler, percentage, progressColor, Console.CursorLeft, progressChar, maxLineWidth); handler.Flush(); diff --git a/PrettyConsole/WriteExtensions.cs b/PrettyConsole/WriteExtensions.cs index cb689e0..0c5c2c9 100755 --- a/PrettyConsole/WriteExtensions.cs +++ b/PrettyConsole/WriteExtensions.cs @@ -135,9 +135,7 @@ public static void Write(ReadOnlySpan span, OutputPipe pipe, ConsoleColor /// foreground color /// background color public static void Write(ReadOnlySpan span, OutputPipe pipe, ConsoleColor foreground, ConsoleColor background) { - Console.SetColors(foreground, background); - Write(span, pipe); - Console.ResetColor(); + WriteInterpolated(pipe, $"{(foreground, background)}{span}"); } } -} \ No newline at end of file +} diff --git a/PrettyConsole/WriteLineExtensions.cs b/PrettyConsole/WriteLineExtensions.cs index 5feefba..5eb67a6 100755 --- a/PrettyConsole/WriteLineExtensions.cs +++ b/PrettyConsole/WriteLineExtensions.cs @@ -121,9 +121,7 @@ public static void WriteLine(ReadOnlySpan span, OutputPipe pipe, ConsoleCo /// foreground color /// background color public static void WriteLine(ReadOnlySpan span, OutputPipe pipe, ConsoleColor foreground, ConsoleColor background) { - Console.SetColors(foreground, background); - WriteLine(span, pipe); - Console.ResetColor(); + WriteLineInterpolated(pipe, $"{(foreground, background)}{span}"); } } -} \ No newline at end of file +} From be6c033652fa299a57bfb1497261dd1535e45230 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 27 Mar 2026 14:33:36 +0300 Subject: [PATCH 07/16] update agents md --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4722782..bbe19b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,9 @@ Repo-specific agent rules and conventions - Prefer dotnet CLI for making, verifying, and running changes. - When changing a specific project, build/run just that project to validate, not the entire solution. - For tests in `PrettyConsole.UnitTests`, use dotnet run, never dotnet test. +- Treat `PrettyConsoleInterpolatedStringHandler` as the leading output abstraction in the library. Do not modify the handler to fit secondary APIs unless the user explicitly asks for handler changes; instead, realign other APIs to compose through the existing handler-facing APIs and semantics. +- When unifying colored output paths, prefer composing through `WriteInterpolated`/`WriteLineInterpolated` and existing `ConsoleColor` tuple interpolation rather than adding new handler hooks or duplicating ANSI/color-state logic elsewhere. +- If the requested direction may already exist in the worktree, inspect staged changes before designing the implementation so you follow the repository's intended approach instead of inventing a parallel one. - Adhere to .editorconfig in the repo for style and analyzers. - If code needs to be “removed” as part of a change, do not delete files; comment out their contents so they won’t compile. - Avoid reflection/dynamic assembly loading in published library code unless explicitly requested. @@ -46,6 +49,7 @@ High-level architecture and key concepts - `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` buffers interpolated content before emitting it, stays allocation-free, now exposes additional public helpers (including `AppendInline` for composing handlers) and is constructed/consumed by `ref`. `$"..."` calls light up `WriteInterpolated`, `WriteLineInterpolated`, `ReadLine`, `TryReadLine`, `Confirm`, and `RequestAnyInput`. Colors auto-reset, handlers respect the selected pipe/`IFormatProvider`, and `object` arguments that implement `ISpanFormattable` are emitted via the span path before falling back to `IFormattable`/string. `Console.WriteInterpolated`/`WriteLineInterpolated` return the rendered character count (handler-emitted escape sequences excluded). Passing the `WhiteSpace` struct writes padding directly from the handler without allocations. + - The handler has the highest optimization and stability priority in the package. Preserve its behavior and shape unless handler work is the explicit task; changes elsewhere should conform to the handler, not force the handler to accommodate them. - Mid-span ANSI sequences are intentionally unsupported: every ANSI sequence (from `ConsoleColor` conversions or `Markup`) is only safe when emitted via an interpolated hole, which lets the handler isolate the escape and keep width calculations consistent. Do not try to "account" for mid-span sequences or adjust character counts manually when discussing this repo. - Coloring model - `ConsoleColor` 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. `AnsiColors` is now public if you need raw ANSI sequences from `ConsoleColor`. @@ -53,6 +57,7 @@ High-level architecture and key concepts - 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` are the default output APIs and host the interpolated-string handler; this path already covers high-performance formatting and coloring. Keep `Write`/`WriteLine` overloads (`ISpanFormattable`/`ReadOnlySpan`) for rare low-level scenarios where callers intentionally bypass the handler with custom formatting pipelines. Those overloads still rent buffers from `ArrayPool.Shared` and reset colors. + - If these low-level overloads need to be brought back into alignment with the main output model, prefer delegating to the existing interpolated APIs rather than changing handler internals to preserve legacy low-level behavior. - TextWriter helpers - `ConsoleContext` surfaces the live `Out`/`Error` writers (now with public setters for test doubles) and keeps helpers like `GetWidthOrDefault`. Use `Console.WriteWhiteSpaces(int length)` for the default output path and specify `OutputPipe.Error` only when needed; `TextWriter.WriteWhiteSpaces(int)` remains available on the writers if you already have them on hand. - Inputs @@ -78,6 +83,7 @@ Testing structure and workflows - PrettyConsole.UnitTests - Execute with `dotnet run --project PrettyConsole.UnitTests -- --no-progress --disable-logo`. - Coverage includes progress rendering, handler formatting behavior, and `LiveConsoleRegion` scenarios such as retained redraw, progress rendering, live-region clearing, and pipe-target changes. Keep these behaviors in sync with docs. + - Do not run `dotnet build` and `dotnet run` for projects that share the same outputs in parallel; serialize those commands to avoid transient file-lock failures in `obj/` and `bin/`. Notes and gotchas From b0f24a6539d9fac54854ef9415e06dd4db00b5ea Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 27 Mar 2026 15:22:32 +0300 Subject: [PATCH 08/16] Add ANSI tests to guard windows output --- .../ConsoleContextWindowsAnsiTests.cs | 42 +++++++++++++++++++ PrettyConsole/ConsoleContext.WindowsAnsi.cs | 32 ++++++++++++++ PrettyConsole/ConsoleContext.cs | 4 +- 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 PrettyConsole.UnitTests/ConsoleContextWindowsAnsiTests.cs create mode 100755 PrettyConsole/ConsoleContext.WindowsAnsi.cs diff --git a/PrettyConsole.UnitTests/ConsoleContextWindowsAnsiTests.cs b/PrettyConsole.UnitTests/ConsoleContextWindowsAnsiTests.cs new file mode 100644 index 0000000..c2f9b2b --- /dev/null +++ b/PrettyConsole.UnitTests/ConsoleContextWindowsAnsiTests.cs @@ -0,0 +1,42 @@ +#if WINDOWS +using System.Runtime.InteropServices; + +namespace PrettyConsole.UnitTests; + +[SkipWhenConsoleUnavailable] +public static partial class ConsoleContextWindowsAnsiTests { + [Test] + public static async Task IsAnsiSupported_MatchesConsoleModeVirtualTerminalFlag() { + bool expected = TryGetVirtualTerminalSupport(out bool isSupported) && isSupported; + + await Assert.That(ConsoleContext.IsAnsiSupported).IsEqualTo(expected); + } + + private static bool TryGetVirtualTerminalSupport(out bool isSupported) { + nint outputHandle = GetStdHandle(StdOutputHandle); + if (outputHandle == 0 || outputHandle == InvalidHandleValue) { + isSupported = false; + return false; + } + + if (!GetConsoleMode(outputHandle, out uint consoleMode)) { + isSupported = false; + return false; + } + + isSupported = (consoleMode & EnableVirtualTerminalProcessing) != 0; + return true; + } + + private const int StdOutputHandle = -11; + private const uint EnableVirtualTerminalProcessing = 0x0004; + private static readonly nint InvalidHandleValue = -1; + + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial nint GetStdHandle(int nStdHandle); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetConsoleMode(nint hConsoleHandle, out uint lpMode); +} +#endif diff --git a/PrettyConsole/ConsoleContext.WindowsAnsi.cs b/PrettyConsole/ConsoleContext.WindowsAnsi.cs new file mode 100755 index 0000000..3c2f053 --- /dev/null +++ b/PrettyConsole/ConsoleContext.WindowsAnsi.cs @@ -0,0 +1,32 @@ +using System.Runtime.InteropServices; + +namespace PrettyConsole; + +public static partial class ConsoleContext { +#if WINDOWS + /// + /// Holds a value that checks whether ANSI is supported in the current console (WINDOWS ONLY) + /// + public static readonly bool IsAnsiSupported = GetIsAnsiSupported(); + + private static bool GetIsAnsiSupported() { + nint outputHandle = GetStdHandle(StdOutputHandle); + if (outputHandle == 0 || outputHandle == InvalidHandleValue) { + return false; + } + + return GetConsoleMode(outputHandle, out uint consoleMode) + && (consoleMode & EnableVirtualTerminalProcessing) != 0; + } + private const int StdOutputHandle = -11; + private const uint EnableVirtualTerminalProcessing = 0x0004; + private static readonly nint InvalidHandleValue = -1; + + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial nint GetStdHandle(int nStdHandle); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetConsoleMode(nint hConsoleHandle, out uint lpMode); +#endif +} diff --git a/PrettyConsole/ConsoleContext.cs b/PrettyConsole/ConsoleContext.cs index 1cb81b0..fdaf081 100755 --- a/PrettyConsole/ConsoleContext.cs +++ b/PrettyConsole/ConsoleContext.cs @@ -9,7 +9,7 @@ namespace PrettyConsole; [UnsupportedOSPlatform("browser")] [UnsupportedOSPlatform("ios")] [UnsupportedOSPlatform("tvos")] -public static class ConsoleContext { +public static partial class ConsoleContext { /// /// The standard output stream. /// @@ -76,4 +76,4 @@ public void WriteWhiteSpaces(int length) { } private static readonly string WhiteSpaces = new(' ', 256); -} \ No newline at end of file +} From c973757d638dd0d882336959e1d7e1f3b6d45f73 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 27 Mar 2026 16:32:01 +0300 Subject: [PATCH 09/16] Fix windows checks --- .../ConsoleContextWindowsAnsiTests.cs | 10 ++++++---- PrettyConsole.UnitTests/PrettyConsole.UnitTests.csproj | 3 ++- PrettyConsole/ConsoleContext.WindowsAnsi.cs | 9 ++++++--- PrettyConsole/PrettyConsole.csproj | 1 + 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/PrettyConsole.UnitTests/ConsoleContextWindowsAnsiTests.cs b/PrettyConsole.UnitTests/ConsoleContextWindowsAnsiTests.cs index c2f9b2b..dc88d48 100644 --- a/PrettyConsole.UnitTests/ConsoleContextWindowsAnsiTests.cs +++ b/PrettyConsole.UnitTests/ConsoleContextWindowsAnsiTests.cs @@ -1,12 +1,15 @@ -#if WINDOWS using System.Runtime.InteropServices; namespace PrettyConsole.UnitTests; [SkipWhenConsoleUnavailable] -public static partial class ConsoleContextWindowsAnsiTests { +public partial class ConsoleContextWindowsAnsiTests { [Test] - public static async Task IsAnsiSupported_MatchesConsoleModeVirtualTerminalFlag() { + public async Task IsAnsiSupported_MatchesConsoleModeVirtualTerminalFlag() { + if (!OperatingSystem.IsWindows()) { + return; + } + bool expected = TryGetVirtualTerminalSupport(out bool isSupported) && isSupported; await Assert.That(ConsoleContext.IsAnsiSupported).IsEqualTo(expected); @@ -39,4 +42,3 @@ private static bool TryGetVirtualTerminalSupport(out bool isSupported) { [return: MarshalAs(UnmanagedType.Bool)] private static partial bool GetConsoleMode(nint hConsoleHandle, out uint lpMode); } -#endif diff --git a/PrettyConsole.UnitTests/PrettyConsole.UnitTests.csproj b/PrettyConsole.UnitTests/PrettyConsole.UnitTests.csproj index 6249b39..5d34e0c 100644 --- a/PrettyConsole.UnitTests/PrettyConsole.UnitTests.csproj +++ b/PrettyConsole.UnitTests/PrettyConsole.UnitTests.csproj @@ -5,6 +5,7 @@ enable Exe net10.0 + true @@ -16,4 +17,4 @@ - \ No newline at end of file + diff --git a/PrettyConsole/ConsoleContext.WindowsAnsi.cs b/PrettyConsole/ConsoleContext.WindowsAnsi.cs index 3c2f053..5d63b60 100755 --- a/PrettyConsole/ConsoleContext.WindowsAnsi.cs +++ b/PrettyConsole/ConsoleContext.WindowsAnsi.cs @@ -3,18 +3,22 @@ namespace PrettyConsole; public static partial class ConsoleContext { -#if WINDOWS /// - /// Holds a value that checks whether ANSI is supported in the current console (WINDOWS ONLY) + /// Holds a value that checks whether ANSI is supported in the current console. /// public static readonly bool IsAnsiSupported = GetIsAnsiSupported(); private static bool GetIsAnsiSupported() { + if (!OperatingSystem.IsWindows()) { + return true; + } + nint outputHandle = GetStdHandle(StdOutputHandle); if (outputHandle == 0 || outputHandle == InvalidHandleValue) { return false; } + return GetConsoleMode(outputHandle, out uint consoleMode) && (consoleMode & EnableVirtualTerminalProcessing) != 0; } @@ -28,5 +32,4 @@ private static bool GetIsAnsiSupported() { [LibraryImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static partial bool GetConsoleMode(nint hConsoleHandle, out uint lpMode); -#endif } diff --git a/PrettyConsole/PrettyConsole.csproj b/PrettyConsole/PrettyConsole.csproj index 79fcc07..8cd6e46 100755 --- a/PrettyConsole/PrettyConsole.csproj +++ b/PrettyConsole/PrettyConsole.csproj @@ -28,6 +28,7 @@ git 5.5.0 enable + true MIT True README.md From 038a69bd38cf56daf46521a58638cdde00894ad0 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 27 Mar 2026 16:46:43 +0300 Subject: [PATCH 10/16] cleanup --- ...nsoleContext.WindowsAnsi.cs => ConsoleContext.Ansi.cs} | 0 PrettyConsole/GlobalSuppressions.cs | 8 -------- PrettyConsole/PrettyConsole.csproj | 1 + .../PrettyConsoleInterpolatedStringHandlerBuilder.cs | 4 +--- 4 files changed, 2 insertions(+), 11 deletions(-) rename PrettyConsole/{ConsoleContext.WindowsAnsi.cs => ConsoleContext.Ansi.cs} (100%) delete mode 100644 PrettyConsole/GlobalSuppressions.cs diff --git a/PrettyConsole/ConsoleContext.WindowsAnsi.cs b/PrettyConsole/ConsoleContext.Ansi.cs similarity index 100% rename from PrettyConsole/ConsoleContext.WindowsAnsi.cs rename to PrettyConsole/ConsoleContext.Ansi.cs diff --git a/PrettyConsole/GlobalSuppressions.cs b/PrettyConsole/GlobalSuppressions.cs deleted file mode 100644 index 203ad04..0000000 --- a/PrettyConsole/GlobalSuppressions.cs +++ /dev/null @@ -1,8 +0,0 @@ -// -using System.Diagnostics.CodeAnalysis; - -// Mainly used for PrettyConsoleInterpolatedStringHandler (will be used at call site by the compiler) -[assembly: SuppressMessage( - "Style", - "IDE0060:Remove unused parameter", - Justification = "Parameters may be intentionally unused for API shape consistency or interface compliance.")] \ No newline at end of file diff --git a/PrettyConsole/PrettyConsole.csproj b/PrettyConsole/PrettyConsole.csproj index 8cd6e46..9948fd2 100755 --- a/PrettyConsole/PrettyConsole.csproj +++ b/PrettyConsole/PrettyConsole.csproj @@ -32,6 +32,7 @@ MIT True README.md + IDE0060;CA1822 diff --git a/PrettyConsole/PrettyConsoleInterpolatedStringHandlerBuilder.cs b/PrettyConsole/PrettyConsoleInterpolatedStringHandlerBuilder.cs index fc51a5f..3555832 100644 --- a/PrettyConsole/PrettyConsoleInterpolatedStringHandlerBuilder.cs +++ b/PrettyConsole/PrettyConsoleInterpolatedStringHandlerBuilder.cs @@ -1,6 +1,5 @@ namespace PrettyConsole; -#pragma warning disable CA1822 // Mark members as static /// /// Provides an API to build a string handler. /// @@ -25,5 +24,4 @@ public sealed class PrettyConsoleInterpolatedStringHandlerBuilder { /// /// public ref PrettyConsoleInterpolatedStringHandler Build(OutputPipe pipe, [InterpolatedStringHandlerArgument(nameof(pipe))] ref PrettyConsoleInterpolatedStringHandler handler) => ref handler; -} -#pragma warning restore CA1822 // Mark members as static \ No newline at end of file +} \ No newline at end of file From 989e29179b0f09cd014622b271b46309a7f684d4 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 27 Mar 2026 17:38:49 +0300 Subject: [PATCH 11/16] Re-implement markup as token --- PrettyConsole.UnitTests/MarkupTests.cs | 42 ++++++++++++++----- ...tyConsoleInterpolatedStringHandlerTests.cs | 11 +++++ PrettyConsole/Markup.cs | 28 ++++++++----- .../PrettyConsoleInterpolatedStringHandler.cs | 16 ++++++- 4 files changed, 73 insertions(+), 24 deletions(-) diff --git a/PrettyConsole.UnitTests/MarkupTests.cs b/PrettyConsole.UnitTests/MarkupTests.cs index 85ff101..c578207 100644 --- a/PrettyConsole.UnitTests/MarkupTests.cs +++ b/PrettyConsole.UnitTests/MarkupTests.cs @@ -2,16 +2,36 @@ namespace PrettyConsole.UnitTests; public class MarkupTests { [Test] - [Arguments(Markup.Reset, "\e[0m")] - [Arguments(Markup.Underline, "\e[4m")] - [Arguments(Markup.ResetUnderline, "\e[24m")] - [Arguments(Markup.Bold, "\e[1m")] - [Arguments(Markup.ResetBold, "\e[22m")] - [Arguments(Markup.Italic, "\e[3m")] - [Arguments(Markup.ResetItalic, "\e[23m")] - [Arguments(Markup.Strikethrough, "\e[9m")] - [Arguments(Markup.ResetStrikethrough, "\e[29m")] - public async Task Markup_Constants(string actual, string expected) { - await Assert.That(actual).IsEqualTo(expected).IgnoringCase(); + [Arguments("\e[0m")] + [Arguments("\e[4m")] + [Arguments("\e[24m")] + [Arguments("\e[1m")] + [Arguments("\e[22m")] + [Arguments("\e[3m")] + [Arguments("\e[23m")] + [Arguments("\e[9m")] + [Arguments("\e[29m")] + public async Task Markup_BuiltInTokens_ExposeExpectedSequences(string expected) { + MarkupToken actual = expected switch { + "\e[0m" => Markup.Reset, + "\e[4m" => Markup.Underline, + "\e[24m" => Markup.ResetUnderline, + "\e[1m" => Markup.Bold, + "\e[22m" => Markup.ResetBold, + "\e[3m" => Markup.Italic, + "\e[23m" => Markup.ResetItalic, + "\e[9m" => Markup.Strikethrough, + "\e[29m" => Markup.ResetStrikethrough, + _ => throw new InvalidOperationException("Unexpected markup token test case.") + }; + + await Assert.That(actual.Value).IsEqualTo(expected).IgnoringCase(); + } + + [Test] + public async Task MarkupToken_CustomValue_UsesProvidedSequence() { + var token = new MarkupToken("\e[5m"); + + await Assert.That(token.Value).IsEqualTo("\e[5m"); } } \ No newline at end of file diff --git a/PrettyConsole.UnitTests/PrettyConsoleInterpolatedStringHandlerTests.cs b/PrettyConsole.UnitTests/PrettyConsoleInterpolatedStringHandlerTests.cs index 451b8c5..56da2f1 100644 --- a/PrettyConsole.UnitTests/PrettyConsoleInterpolatedStringHandlerTests.cs +++ b/PrettyConsole.UnitTests/PrettyConsoleInterpolatedStringHandlerTests.cs @@ -135,6 +135,17 @@ public async Task CharsWritten_IgnoresAnsiColorAndMarkupSequences() { await Assert.That(chars).IsEqualTo(2); } + [Test] + public async Task CharsWritten_IgnoresCustomMarkupTokenSequences() { + var blink = new MarkupToken("\e[5m"); + int chars = Console.WriteInterpolated($"{blink}Hi{Markup.Reset}"); + + var written = Utilities.StripAnsiSequences(_writer.ToStringAndFlush()); + + await Assert.That(written).IsEqualTo("Hi"); + await Assert.That(chars).IsEqualTo(2); + } + [Test] [Arguments(5, " OK")] [Arguments(-5, "OK ")] diff --git a/PrettyConsole/Markup.cs b/PrettyConsole/Markup.cs index 31af8c1..183cdce 100644 --- a/PrettyConsole/Markup.cs +++ b/PrettyConsole/Markup.cs @@ -1,51 +1,57 @@ namespace PrettyConsole; /// -/// Provides ANSI escape sequences for simple inline decorations. +/// Represents a guarded markup token that emits an ANSI escape sequence through . +/// +/// The ANSI sequence +public record MarkupToken(string Value); + +/// +/// Provides guarded markup tokens for simple inline decorations. /// public static class Markup { /// /// Resets all decorations and colors. /// - public const string Reset = "\e[0m"; + public static readonly MarkupToken Reset = new("\e[0m"); /// /// Enables underlined text. /// - public const string Underline = "\e[4m"; + public static readonly MarkupToken Underline = new("\e[4m"); /// /// Disables underlined text. /// - public const string ResetUnderline = "\e[24m"; + public static readonly MarkupToken ResetUnderline = new("\e[24m"); /// /// Enables bold text. /// - public const string Bold = "\e[1m"; + public static readonly MarkupToken Bold = new("\e[1m"); /// /// Disables bold text. /// - public const string ResetBold = "\e[22m"; + public static readonly MarkupToken ResetBold = new("\e[22m"); /// /// Enables italic text. /// - public const string Italic = "\e[3m"; + public static readonly MarkupToken Italic = new("\e[3m"); /// /// Disables italic text. /// - public const string ResetItalic = "\e[23m"; + public static readonly MarkupToken ResetItalic = new("\e[23m"); /// /// Enables strikethrough text. /// - public const string Strikethrough = "\e[9m"; + public static readonly MarkupToken Strikethrough = new("\e[9m"); /// /// Disables strikethrough text. /// - public const string ResetStrikethrough = "\e[29m"; -} \ No newline at end of file + public static readonly MarkupToken ResetStrikethrough = new("\e[29m"); +} diff --git a/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs b/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs index b8d16f7..2f9b164 100644 --- a/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs +++ b/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs @@ -8,6 +8,7 @@ namespace PrettyConsole; [InterpolatedStringHandler] public struct PrettyConsoleInterpolatedStringHandler { private static readonly ArrayPool BufferPool = ArrayPool.Shared; + private static readonly bool DisableAnsi = !ConsoleContext.IsAnsiSupported; private bool _flushed; private char[] _buffer; @@ -129,6 +130,17 @@ public void AppendFormatted(char value, int alignment = 0) { AppendSpan(buffer, alignment); } + /// + /// Appends a guarded markup token. + /// + /// The markup token to emit. + public void AppendFormatted(MarkupToken token) { + if (DisableAnsi || _isRedirected) return; + + ThrowIfFlushed(); + AppendSpanCore(token.Value); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ChangeForeground(ConsoleColor foreground) => AppendSpanCore(AnsiColors.Foreground(foreground)); @@ -139,7 +151,7 @@ public void AppendFormatted(char value, int alignment = 0) { /// Sets the foreground color to . /// public void AppendFormatted(ConsoleColor color) { - if (_isRedirected) return; + if (DisableAnsi || _isRedirected) return; if (_currentForeground != color) { ThrowIfFlushed(); _currentForeground = color; @@ -151,7 +163,7 @@ public void AppendFormatted(ConsoleColor color) { /// Sets the background color to . /// public void AppendFormattedBackground(ConsoleColor color) { - if (_isRedirected) return; + if (DisableAnsi || _isRedirected) return; if (_currentBackground != color) { ThrowIfFlushed(); _currentBackground = color; From 1785a87c5e01a756843d33e214e4d8bccbadde8a Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 27 Mar 2026 19:53:08 +0300 Subject: [PATCH 12/16] Collapse all Ansi under AnsiToken --- ...ks.StyledOutputBenchmarks-report-github.md | 20 +-- Benchmarks/StyledOutputBenchmark.cs | 10 +- PrettyConsole.UnitTests/ConsoleColorTests.cs | 79 +++++++++-- PrettyConsole.UnitTests/MarkupTests.cs | 8 +- ...tyConsoleInterpolatedStringHandlerTests.cs | 32 ++++- PrettyConsole/AnsiColors.cs | 120 ++++++---------- PrettyConsole/AnsiToken.cs | 7 + PrettyConsole/Color.cs | 128 ++++++++++++++++++ PrettyConsole/Markup.cs | 26 ++-- .../PrettyConsoleInterpolatedStringHandler.cs | 37 ++--- README.md | 6 +- 11 files changed, 316 insertions(+), 157 deletions(-) create mode 100644 PrettyConsole/AnsiToken.cs create mode 100644 PrettyConsole/Color.cs diff --git a/Benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.StyledOutputBenchmarks-report-github.md b/Benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.StyledOutputBenchmarks-report-github.md index a7a0e44..c31b012 100644 --- a/Benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.StyledOutputBenchmarks-report-github.md +++ b/Benchmarks/BenchmarkDotNet.Artifacts/results/Benchmarks.StyledOutputBenchmarks-report-github.md @@ -1,18 +1,18 @@ ``` -BenchmarkDotNet v0.15.8, macOS Tahoe 26.1 (25B78) [Darwin 25.1.0] +BenchmarkDotNet v0.15.8, macOS Tahoe 26.3.1 (a) (25D771280a) [Darwin 25.3.0] Apple M2 Pro, 1 CPU, 10 logical and 10 physical cores -.NET SDK 10.0.100 - [Host] : .NET 10.0.0 (10.0.0, 10.0.25.52411), Arm64 RyuJIT armv8.0-a - PGO1 : .NET 10.0.0 (10.0.0, 10.0.25.52411), Arm64 RyuJIT armv8.0-a +.NET SDK 10.0.201 + [Host] : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a + PGO1 : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a -Job=PGO1 OutlierMode=RemoveAll EnvironmentVariables=DOTNET_TieredPGO=1 -IterationCount=30 IterationTime=100ms LaunchCount=3 -WarmupCount=5 +Job=PGO1 OutlierMode=RemoveAll EnvironmentVariables=DOTNET_TieredPGO=1 +IterationCount=30 IterationTime=100ms LaunchCount=3 +WarmupCount=5 ``` | Method | Mean | Ratio | Gen0 | Allocated | Alloc Ratio | |--------------- |------------:|--------------:|-------:|----------:|--------------:| -| PrettyConsole | 55.96 ns | 90.23x faster | - | - | NA | -| SpectreConsole | 5,046.29 ns | baseline | 2.1193 | 17840 B | | -| SystemConsole | 71.64 ns | 70.44x faster | 0.0022 | 24 B | 743.333x less | +| PrettyConsole | 53.10 ns | 92.14x faster | - | - | NA | +| SpectreConsole | 4,889.25 ns | baseline | 2.0880 | 17840 B | | +| SystemConsole | 70.48 ns | 69.37x faster | 0.0022 | 24 B | 743.333x less | diff --git a/Benchmarks/StyledOutputBenchmark.cs b/Benchmarks/StyledOutputBenchmark.cs index d8cb3d4..8352462 100644 --- a/Benchmarks/StyledOutputBenchmark.cs +++ b/Benchmarks/StyledOutputBenchmark.cs @@ -4,7 +4,7 @@ using Spectre.Console; -using static System.ConsoleColor; +using static PrettyConsole.Color; namespace Benchmarks; @@ -39,7 +39,7 @@ public void GlobalCleanup() { [Benchmark] public int PrettyConsole() { - Console.WriteLineInterpolated($"Hello {Green}John{ConsoleColor.DefaultForeground}, status = {Cyan}{Percentage}{ConsoleColor.DefaultForeground}%, elapsed = {Yellow}{Elapsed:c}"); + Console.WriteLineInterpolated($"Hello {Green}John{Default}, status = {Cyan}{Percentage}{Default}%, elapsed = {Yellow}{Elapsed:c}"); return int.MaxValue; } @@ -52,15 +52,15 @@ public int SpectreConsole() { [Benchmark] public int SystemConsole() { Console.Write("Hello "); - Console.ForegroundColor = Green; + Console.ForegroundColor = ConsoleColor.Green; Console.Write("John"); Console.ResetColor(); Console.Write(", status = "); - Console.ForegroundColor = Cyan; + Console.ForegroundColor = ConsoleColor.Cyan; Console.Write(Percentage); Console.ResetColor(); Console.Write("%, elapsed = "); - Console.ForegroundColor = Yellow; + Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine("{0:c}", Elapsed); Console.ResetColor(); return int.MaxValue; diff --git a/PrettyConsole.UnitTests/ConsoleColorTests.cs b/PrettyConsole.UnitTests/ConsoleColorTests.cs index 5d43ac9..e0d9f92 100644 --- a/PrettyConsole.UnitTests/ConsoleColorTests.cs +++ b/PrettyConsole.UnitTests/ConsoleColorTests.cs @@ -25,13 +25,15 @@ public async Task ConsoleColor_DefaultColors() { [Test] public async Task AnsiColors_DefaultForeground_UsesResetSequence() { var sequence = AnsiColors.Foreground((ConsoleColor)(-1)); - await Assert.That(sequence).IsEqualTo("\e[39m"); + await Assert.That(sequence.Value).IsEqualTo("\e[39m"); + await Assert.That(sequence).IsSameReferenceAs(Color.DefaultForeground); } [Test] public async Task AnsiColors_DefaultBackground_UsesResetSequence() { var sequence = AnsiColors.Background((ConsoleColor)(-1)); - await Assert.That(sequence).IsEqualTo("\e[49m"); + await Assert.That(sequence.Value).IsEqualTo("\e[49m"); + await Assert.That(sequence).IsSameReferenceAs(Color.DefaultBackground); } [Test] @@ -53,7 +55,7 @@ public async Task AnsiColors_DefaultBackground_UsesResetSequence() { [Arguments(White, "\e[97m")] public async Task AnsiColors_ForegroundSequences(ConsoleColor color, string expectedSequence) { var sequence = AnsiColors.Foreground(color); - await Assert.That(sequence).IsEqualTo(expectedSequence); + await Assert.That(sequence.Value).IsEqualTo(expectedSequence); } [Test] @@ -75,21 +77,68 @@ public async Task AnsiColors_ForegroundSequences(ConsoleColor color, string expe [Arguments(White, "\e[107m")] public async Task AnsiColors_BackgroundSequences(ConsoleColor color, string expectedSequence) { var sequence = AnsiColors.Background(color); - await Assert.That(sequence).IsEqualTo(expectedSequence); + await Assert.That(sequence.Value).IsEqualTo(expectedSequence); } [Test] - public async Task AnsiColors_InternalBuilders_MatchPublicAccessors() { - foreach (var color in Enum.GetValues()) { - var fgBuilt = AnsiColors.BuildForegroundSequence(color); - var bgBuilt = AnsiColors.BuildBackgroundSequence(color); + public async Task Color_BuiltInTokens_ExposeExpectedSequences() { + await Assert.That(Color.Default.Value).IsEqualTo("\e[39m\e[49m"); + await Assert.That(Color.DefaultForeground.Value).IsEqualTo("\e[39m"); + await Assert.That(Color.DefaultBackground.Value).IsEqualTo("\e[49m"); + await Assert.That(Color.Green.Value).IsEqualTo("\e[92m"); + await Assert.That(Color.GreenBackground.Value).IsEqualTo("\e[102m"); + } - await Assert.That(AnsiColors.Foreground(color)).IsEqualTo(fgBuilt); - await Assert.That(AnsiColors.Background(color)).IsEqualTo(bgBuilt); - } + [Test] + public async Task Color_BuiltInTokens_AliasAnsiColorsCache() { + await Assert.That(Color.Green).IsSameReferenceAs(AnsiColors.Foreground(Green)); + await Assert.That(Color.GreenBackground).IsSameReferenceAs(AnsiColors.Background(Green)); + } - ConsoleColor @default = (ConsoleColor)(-1); - await Assert.That(AnsiColors.Foreground(@default)).IsEqualTo(AnsiColors.ForegroundResetSequence); - await Assert.That(AnsiColors.Background(@default)).IsEqualTo(AnsiColors.BackgroundResetSequence); + [Test] + public async Task AnsiColors_IndexOrder_MatchesConsoleColorEnumOrder() { + AnsiToken[] expectedForeground = [ + Color.Black, + Color.DarkBlue, + Color.DarkGreen, + Color.DarkCyan, + Color.DarkRed, + Color.DarkMagenta, + Color.DarkYellow, + Color.Gray, + Color.DarkGray, + Color.Blue, + Color.Green, + Color.Cyan, + Color.Red, + Color.Magenta, + Color.Yellow, + Color.White + ]; + + AnsiToken[] expectedBackground = [ + Color.BlackBackground, + Color.DarkBlueBackground, + Color.DarkGreenBackground, + Color.DarkCyanBackground, + Color.DarkRedBackground, + Color.DarkMagentaBackground, + Color.DarkYellowBackground, + Color.GrayBackground, + Color.DarkGrayBackground, + Color.BlueBackground, + Color.GreenBackground, + Color.CyanBackground, + Color.RedBackground, + Color.MagentaBackground, + Color.YellowBackground, + Color.WhiteBackground + ]; + + foreach (var color in Enum.GetValues()) { + int index = (int)color; + await Assert.That(AnsiColors.Foreground(color)).IsSameReferenceAs(expectedForeground[index]); + await Assert.That(AnsiColors.Background(color)).IsSameReferenceAs(expectedBackground[index]); + } } -} \ No newline at end of file +} diff --git a/PrettyConsole.UnitTests/MarkupTests.cs b/PrettyConsole.UnitTests/MarkupTests.cs index c578207..e698922 100644 --- a/PrettyConsole.UnitTests/MarkupTests.cs +++ b/PrettyConsole.UnitTests/MarkupTests.cs @@ -12,7 +12,7 @@ public class MarkupTests { [Arguments("\e[9m")] [Arguments("\e[29m")] public async Task Markup_BuiltInTokens_ExposeExpectedSequences(string expected) { - MarkupToken actual = expected switch { + AnsiToken actual = expected switch { "\e[0m" => Markup.Reset, "\e[4m" => Markup.Underline, "\e[24m" => Markup.ResetUnderline, @@ -29,9 +29,9 @@ public async Task Markup_BuiltInTokens_ExposeExpectedSequences(string expected) } [Test] - public async Task MarkupToken_CustomValue_UsesProvidedSequence() { - var token = new MarkupToken("\e[5m"); + public async Task AnsiToken_CustomValue_UsesProvidedSequence() { + var token = new AnsiToken("\e[5m"); await Assert.That(token.Value).IsEqualTo("\e[5m"); } -} \ No newline at end of file +} diff --git a/PrettyConsole.UnitTests/PrettyConsoleInterpolatedStringHandlerTests.cs b/PrettyConsole.UnitTests/PrettyConsoleInterpolatedStringHandlerTests.cs index 56da2f1..dfd429d 100644 --- a/PrettyConsole.UnitTests/PrettyConsoleInterpolatedStringHandlerTests.cs +++ b/PrettyConsole.UnitTests/PrettyConsoleInterpolatedStringHandlerTests.cs @@ -136,8 +136,8 @@ public async Task CharsWritten_IgnoresAnsiColorAndMarkupSequences() { } [Test] - public async Task CharsWritten_IgnoresCustomMarkupTokenSequences() { - var blink = new MarkupToken("\e[5m"); + public async Task CharsWritten_IgnoresCustomAnsiTokenSequences() { + var blink = new AnsiToken("\e[5m"); int chars = Console.WriteInterpolated($"{blink}Hi{Markup.Reset}"); var written = Utilities.StripAnsiSequences(_writer.ToStringAndFlush()); @@ -146,6 +146,28 @@ public async Task CharsWritten_IgnoresCustomMarkupTokenSequences() { await Assert.That(chars).IsEqualTo(2); } + [Test] + public async Task ColorToken_Flush_UsesComposedDefaultReset() { + var originalOut = Out; + try { + (_, var isRedirected) = GetPipeTargetAndState(OutputPipe.Out); + Out = Utilities.GetWriter(out var writer); + + var handler = new PrettyConsoleInterpolatedStringHandler(OutputPipe.Out); + handler.AppendFormatted(Color.Green); + handler.AppendSpan("Hello"); + handler.Flush(); + + if (isRedirected) { + await Assert.That(writer.ToString()).IsEqualTo("Hello"); + } else { + await Assert.That(writer.ToString()).IsEqualTo($"{Color.Green.Value}Hello{Color.Default.Value}"); + } + } finally { + Out = originalOut; + } + } + [Test] [Arguments(5, " OK")] [Arguments(-5, "OK ")] @@ -331,7 +353,7 @@ public async Task ManualCtor() { if (isRedirected) { await Assert.That(new string(handler.WrittenSpan)).IsEqualTo("Hello"); } else { - await Assert.That(new string(handler.WrittenSpan)).IsEqualTo($"{AnsiColors.Foreground(Green)}Hello{AnsiColors.ForegroundResetSequence}"); + await Assert.That(new string(handler.WrittenSpan)).IsEqualTo($"{AnsiColors.Foreground(Green).Value}Hello{Color.Default.Value}"); } handler.FlushWithoutWrite(); @@ -347,9 +369,9 @@ public async Task NestedHandler() { if (isRedirected) { await Assert.That(new string(handler.WrittenSpan)).IsEqualTo("Hello"); } else { - await Assert.That(new string(handler.WrittenSpan)).IsEqualTo($"{AnsiColors.Foreground(Green)}Hello{AnsiColors.ForegroundResetSequence}"); + await Assert.That(new string(handler.WrittenSpan)).IsEqualTo($"{AnsiColors.Foreground(Green).Value}Hello{Color.Default.Value}"); } handler.FlushWithoutWrite(); } -} \ No newline at end of file +} diff --git a/PrettyConsole/AnsiColors.cs b/PrettyConsole/AnsiColors.cs index 5f963ca..f6f7305 100644 --- a/PrettyConsole/AnsiColors.cs +++ b/PrettyConsole/AnsiColors.cs @@ -1,93 +1,63 @@ namespace PrettyConsole; /// -/// Provides ANSI sequences for the common s. +/// Provides cached ANSI tokens for the common s. /// public static class AnsiColors { - /// - /// A sequence to reset foreground color. - /// - public const string ForegroundResetSequence = "\e[39m"; - - /// - /// A sequence to reset background color. - /// - public const string BackgroundResetSequence = "\e[49m"; - - private static readonly string[] ForegroundCodes; - private static readonly string[] BackgroundCodes; - - static AnsiColors() { - ForegroundCodes = new string[16]; - BackgroundCodes = new string[16]; - foreach (var color in Enum.GetValues()) { - int index = (int)color; - ForegroundCodes[index] = BuildForegroundSequence(color); - BackgroundCodes[index] = BuildBackgroundSequence(color); - } - } + private static readonly AnsiToken[] ForegroundCodes = [ + Color.Black, + Color.DarkBlue, + Color.DarkGreen, + Color.DarkCyan, + Color.DarkRed, + Color.DarkMagenta, + Color.DarkYellow, + Color.Gray, + Color.DarkGray, + Color.Blue, + Color.Green, + Color.Cyan, + Color.Red, + Color.Magenta, + Color.Yellow, + Color.White + ]; + + private static readonly AnsiToken[] BackgroundCodes = [ + Color.BlackBackground, + Color.DarkBlueBackground, + Color.DarkGreenBackground, + Color.DarkCyanBackground, + Color.DarkRedBackground, + Color.DarkMagentaBackground, + Color.DarkYellowBackground, + Color.GrayBackground, + Color.DarkGrayBackground, + Color.BlueBackground, + Color.GreenBackground, + Color.CyanBackground, + Color.RedBackground, + Color.MagentaBackground, + Color.YellowBackground, + Color.WhiteBackground + ]; /// - /// Gets the ANSI sequence for the specified foreground color. + /// Gets the cached ANSI token for the specified foreground color. /// - public static string Foreground(ConsoleColor color) { + public static AnsiToken Foreground(ConsoleColor color) { int index = (int)color; - if (index == -1) return ForegroundResetSequence; + if (index == -1) return Color.DefaultForeground; return ForegroundCodes[index]; } /// - /// Gets the ANSI sequence for the specified background color. + /// Gets the cached ANSI token for the specified background color. /// - public static string Background(ConsoleColor color) { + public static AnsiToken Background(ConsoleColor color) { int index = (int)color; - if (index == -1) return BackgroundResetSequence; + if (index == -1) return Color.DefaultBackground; return BackgroundCodes[index]; } - - - internal static string BuildForegroundSequence(ConsoleColor color) { - return color switch { - ConsoleColor.Black => "\e[30m", - ConsoleColor.DarkBlue => "\e[34m", - ConsoleColor.DarkGreen => "\e[32m", - ConsoleColor.DarkCyan => "\e[36m", - ConsoleColor.DarkRed => "\e[31m", - ConsoleColor.DarkMagenta => "\e[35m", - ConsoleColor.DarkYellow => "\e[33m", - ConsoleColor.Gray => "\e[37m", - ConsoleColor.DarkGray => "\e[90m", - ConsoleColor.Blue => "\e[94m", - ConsoleColor.Green => "\e[92m", - ConsoleColor.Cyan => "\e[96m", - ConsoleColor.Red => "\e[91m", - ConsoleColor.Magenta => "\e[95m", - ConsoleColor.Yellow => "\e[93m", - ConsoleColor.White => "\e[97m", - _ => ForegroundResetSequence - }; - } - - internal static string BuildBackgroundSequence(ConsoleColor color) { - return color switch { - ConsoleColor.Black => "\e[40m", - ConsoleColor.DarkBlue => "\e[44m", - ConsoleColor.DarkGreen => "\e[42m", - ConsoleColor.DarkCyan => "\e[46m", - ConsoleColor.DarkRed => "\e[41m", - ConsoleColor.DarkMagenta => "\e[45m", - ConsoleColor.DarkYellow => "\e[43m", - ConsoleColor.Gray => "\e[47m", - ConsoleColor.DarkGray => "\e[100m", - ConsoleColor.Blue => "\e[104m", - ConsoleColor.Green => "\e[102m", - ConsoleColor.Cyan => "\e[106m", - ConsoleColor.Red => "\e[101m", - ConsoleColor.Magenta => "\e[105m", - ConsoleColor.Yellow => "\e[103m", - ConsoleColor.White => "\e[107m", - _ => BackgroundResetSequence - }; - } -} \ No newline at end of file +} diff --git a/PrettyConsole/AnsiToken.cs b/PrettyConsole/AnsiToken.cs new file mode 100644 index 0000000..a30f492 --- /dev/null +++ b/PrettyConsole/AnsiToken.cs @@ -0,0 +1,7 @@ +namespace PrettyConsole; + +/// +/// Represents a guarded ANSI token that emits its escape sequence through . +/// +/// The ANSI sequence +public record AnsiToken(string Value); diff --git a/PrettyConsole/Color.cs b/PrettyConsole/Color.cs new file mode 100644 index 0000000..bfea76d --- /dev/null +++ b/PrettyConsole/Color.cs @@ -0,0 +1,128 @@ +namespace PrettyConsole; + +/// +/// Provides guarded ANSI color tokens for interpolation-friendly color usage. +/// +public static class Color { + /// + /// Resets both foreground and background colors to the terminal defaults. + /// + public static readonly AnsiToken Default = new("\e[39m\e[49m"); + + /// + /// Resets the foreground color to the terminal default. + /// + public static readonly AnsiToken DefaultForeground = new("\e[39m"); + + /// + /// Resets the background color to the terminal default. + /// + public static readonly AnsiToken DefaultBackground = new("\e[49m"); + + /// Foreground token for . + public static readonly AnsiToken Black = new("\e[30m"); + + /// Foreground token for . + public static readonly AnsiToken DarkBlue = new("\e[34m"); + + /// Foreground token for . + public static readonly AnsiToken DarkGreen = new("\e[32m"); + + /// Foreground token for . + public static readonly AnsiToken DarkCyan = new("\e[36m"); + + /// Foreground token for . + public static readonly AnsiToken DarkRed = new("\e[31m"); + + /// Foreground token for . + public static readonly AnsiToken DarkMagenta = new("\e[35m"); + + /// Foreground token for . + public static readonly AnsiToken DarkYellow = new("\e[33m"); + + /// Foreground token for . + public static readonly AnsiToken Gray = new("\e[37m"); + + /// Foreground token for . + public static readonly AnsiToken DarkGray = new("\e[90m"); + + /// Foreground token for . + public static readonly AnsiToken Blue = new("\e[94m"); + + /// Foreground token for . + public static readonly AnsiToken Green = new("\e[92m"); + + /// Foreground token for . + public static readonly AnsiToken Cyan = new("\e[96m"); + + /// Foreground token for . + public static readonly AnsiToken Red = new("\e[91m"); + + /// Foreground token for . + public static readonly AnsiToken Magenta = new("\e[95m"); + + /// Foreground token for . + public static readonly AnsiToken Yellow = new("\e[93m"); + + /// Foreground token for . + public static readonly AnsiToken White = new("\e[97m"); + + + /// Background token for . + public static readonly AnsiToken BlackBackground = new("\e[40m"); + + /// Background token for . + public static readonly AnsiToken DarkBlueBackground = new("\e[44m"); + + /// Background token for . + public static readonly AnsiToken DarkGreenBackground = new("\e[42m"); + + /// Background token for . + public static readonly AnsiToken DarkCyanBackground = new("\e[46m"); + + /// Background token for . + public static readonly AnsiToken DarkRedBackground = new("\e[41m"); + + /// Background token for . + public static readonly AnsiToken DarkMagentaBackground = new("\e[45m"); + + /// Background token for . + public static readonly AnsiToken DarkYellowBackground = new("\e[43m"); + + /// Background token for . + public static readonly AnsiToken GrayBackground = new("\e[47m"); + + /// Background token for . + public static readonly AnsiToken DarkGrayBackground = new("\e[100m"); + + /// Background token for . + public static readonly AnsiToken BlueBackground = new("\e[104m"); + + /// Background token for . + public static readonly AnsiToken GreenBackground = new("\e[102m"); + + /// Background token for . + public static readonly AnsiToken CyanBackground = new("\e[106m"); + + /// Background token for . + public static readonly AnsiToken RedBackground = new("\e[101m"); + + /// Background token for . + public static readonly AnsiToken MagentaBackground = new("\e[105m"); + + /// Background token for . + public static readonly AnsiToken YellowBackground = new("\e[103m"); + + /// Background token for . + public static readonly AnsiToken WhiteBackground = new("\e[107m"); + + /// + /// Gets the cached foreground ANSI token for the provided color. + /// + public static AnsiToken Foreground(ConsoleColor color) => AnsiColors.Foreground(color); + + /// + /// Gets the cached background ANSI token for the provided color. + /// + public static AnsiToken Background(ConsoleColor color) => AnsiColors.Background(color); +} diff --git a/PrettyConsole/Markup.cs b/PrettyConsole/Markup.cs index 183cdce..f1145c3 100644 --- a/PrettyConsole/Markup.cs +++ b/PrettyConsole/Markup.cs @@ -1,57 +1,51 @@ namespace PrettyConsole; /// -/// Represents a guarded markup token that emits an ANSI escape sequence through . -/// -/// The ANSI sequence -public record MarkupToken(string Value); - -/// -/// Provides guarded markup tokens for simple inline decorations. +/// Provides guarded ANSI tokens for simple inline decorations. /// public static class Markup { /// /// Resets all decorations and colors. /// - public static readonly MarkupToken Reset = new("\e[0m"); + public static readonly AnsiToken Reset = new("\e[0m"); /// /// Enables underlined text. /// - public static readonly MarkupToken Underline = new("\e[4m"); + public static readonly AnsiToken Underline = new("\e[4m"); /// /// Disables underlined text. /// - public static readonly MarkupToken ResetUnderline = new("\e[24m"); + public static readonly AnsiToken ResetUnderline = new("\e[24m"); /// /// Enables bold text. /// - public static readonly MarkupToken Bold = new("\e[1m"); + public static readonly AnsiToken Bold = new("\e[1m"); /// /// Disables bold text. /// - public static readonly MarkupToken ResetBold = new("\e[22m"); + public static readonly AnsiToken ResetBold = new("\e[22m"); /// /// Enables italic text. /// - public static readonly MarkupToken Italic = new("\e[3m"); + public static readonly AnsiToken Italic = new("\e[3m"); /// /// Disables italic text. /// - public static readonly MarkupToken ResetItalic = new("\e[23m"); + public static readonly AnsiToken ResetItalic = new("\e[23m"); /// /// Enables strikethrough text. /// - public static readonly MarkupToken Strikethrough = new("\e[9m"); + public static readonly AnsiToken Strikethrough = new("\e[9m"); /// /// Disables strikethrough text. /// - public static readonly MarkupToken ResetStrikethrough = new("\e[29m"); + public static readonly AnsiToken ResetStrikethrough = new("\e[29m"); } diff --git a/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs b/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs index 2f9b164..4d6053d 100644 --- a/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs +++ b/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs @@ -17,8 +17,6 @@ public struct PrettyConsoleInterpolatedStringHandler { private readonly TextWriter _writer; private readonly bool _isRedirected; private readonly IFormatProvider? _provider; - private ConsoleColor _currentForeground; - private ConsoleColor _currentBackground; private readonly Span Written => new(_buffer, 0, _index); @@ -88,8 +86,6 @@ public PrettyConsoleInterpolatedStringHandler(int literalLength, int formattedCo /// Always ; reserved for future short-circuiting. public PrettyConsoleInterpolatedStringHandler(int literalLength, int formattedCount, OutputPipe pipe, IFormatProvider? provider, out bool shouldAppend) { _buffer = BufferPool.Rent(_capacity); - _currentForeground = ConsoleColor.DefaultForeground; - _currentBackground = ConsoleColor.DefaultBackground; (_writer, _isRedirected) = ConsoleContext.GetPipeTargetAndState(pipe); _provider = provider; shouldAppend = true; @@ -131,10 +127,10 @@ public void AppendFormatted(char value, int alignment = 0) { } /// - /// Appends a guarded markup token. + /// Appends a guarded ANSI token. /// /// The markup token to emit. - public void AppendFormatted(MarkupToken token) { + public void AppendFormatted(AnsiToken token) { if (DisableAnsi || _isRedirected) return; ThrowIfFlushed(); @@ -142,21 +138,18 @@ public void AppendFormatted(MarkupToken token) { } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void ChangeForeground(ConsoleColor foreground) => AppendSpanCore(AnsiColors.Foreground(foreground)); + private void ChangeForeground(ConsoleColor foreground) => AppendSpanCore(AnsiColors.Foreground(foreground).Value); [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void ChangeBackground(ConsoleColor background) => AppendSpanCore(AnsiColors.Background(background)); + private void ChangeBackground(ConsoleColor background) => AppendSpanCore(AnsiColors.Background(background).Value); /// /// Sets the foreground color to . /// public void AppendFormatted(ConsoleColor color) { if (DisableAnsi || _isRedirected) return; - if (_currentForeground != color) { - ThrowIfFlushed(); - _currentForeground = color; - ChangeForeground(color); - } + ThrowIfFlushed(); + ChangeForeground(color); } /// @@ -164,11 +157,8 @@ public void AppendFormatted(ConsoleColor color) { /// public void AppendFormattedBackground(ConsoleColor color) { if (DisableAnsi || _isRedirected) return; - if (_currentBackground != color) { - ThrowIfFlushed(); - _currentBackground = color; - ChangeBackground(color); - } + ThrowIfFlushed(); + ChangeBackground(color); } /// @@ -176,8 +166,10 @@ public void AppendFormattedBackground(ConsoleColor color) { /// /// public void AppendFormatted((ConsoleColor Foreground, ConsoleColor Background) colors) { - AppendFormatted(colors.Foreground); - AppendFormattedBackground(colors.Background); + if (DisableAnsi || _isRedirected) return; + ThrowIfFlushed(); + ChangeForeground(colors.Foreground); + ChangeBackground(colors.Background); } /// @@ -518,10 +510,7 @@ private readonly void ThrowIfFlushed() { /// /// Resets the colors of the contents if they were overwritten. /// - public void ResetColors() { - AppendFormatted(ConsoleColor.DefaultForeground); - AppendFormattedBackground(ConsoleColor.DefaultBackground); - } + public void ResetColors() => AppendFormatted(Color.Default); /// /// Clears the internal buffer and returns it to the underlying array pool without writing to the held . diff --git a/README.md b/README.md index 5d08db5..66aa29e 100755 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ BenchmarkDotNet measures [styled output performance](Benchmarks/BenchmarkDotNet. | Method | Mean | Ratio | Gen0 | Allocated | Alloc Ratio | |--------------- |------------:|--------------:|-------:|----------:|--------------:| -| PrettyConsole | 55.96 ns | 90.23x faster | - | - | NA | -| SpectreConsole | 5,046.29 ns | baseline | 2.1193 | 17840 B | | -| SystemConsole | 71.64 ns | 70.44x faster | 0.0022 | 24 B | 743.333x less | +| PrettyConsole | 53.10 ns | 92.14x faster | - | - | NA | +| SpectreConsole | 4,889.25 ns | baseline | 2.0880 | 17840 B | | +| SystemConsole | 70.48 ns | 69.37x faster | 0.0022 | 24 B | 743.333x less | PrettyConsole is **the go-to choice for ultra-low-latency, allocation-free console rendering**, running 90X faster than **Spectre.Console** while allocating nothing and even beating the manual unrolling with the BCL. From 2b78aed7422cac180bc8e1256e26a2f36fa300bd Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 27 Mar 2026 19:57:16 +0300 Subject: [PATCH 13/16] Ensure test correctness on windows --- PrettyConsole.UnitTests/LiveConsoleRegionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs b/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs index 5712a38..7a2e786 100644 --- a/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs +++ b/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs @@ -143,7 +143,7 @@ public async Task Render_WithLoneLineFeeds_CountsOccupiedLinesCorrectly() { Error = Utilities.GetWriter(out var errorWriter); using var region = new LiveConsoleRegion(); - region.Render($"Line1\nLine2\nLine3"); + region.Render($"Line1{Environment.NewLine}Line2{Environment.NewLine}Line3"); await Assert.That(region.OccupiedLines).IsEqualTo(3); await Assert.That(Utilities.StripAnsiSequences(errorWriter.ToString())).Contains("Line1"); From 5a4c19b27dcdc770771983f9f01b4c13f67079d2 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 27 Mar 2026 21:13:00 +0300 Subject: [PATCH 14/16] Initial docs update --- .agents/skills/pretty-console-expert/SKILL.md | 9 +- AGENTS.md | 17 ++-- .../AdvancedOutputsTests.cs | 6 +- PrettyConsole.UnitTests/ConsoleColorTests.cs | 6 ++ .../LiveConsoleRegionTests.cs | 4 +- PrettyConsole.UnitTests/ProgressBarTests.cs | 16 ++-- PrettyConsole/AdvancedOutputExtensions.cs | 29 +++++- PrettyConsole/AnsiToken.cs | 7 +- PrettyConsole/LiveConsoleRegion.cs | 25 ++++-- PrettyConsole/PrettyConsole.csproj | 5 +- PrettyConsole/ProgressBar.cs | 16 ++-- PrettyConsole/Spinner.cs | 6 +- README.md | 90 +++++++++---------- Versions.md | 10 ++- 14 files changed, 145 insertions(+), 101 deletions(-) diff --git a/.agents/skills/pretty-console-expert/SKILL.md b/.agents/skills/pretty-console-expert/SKILL.md index d925c8b..cb7f0d2 100644 --- a/.agents/skills/pretty-console-expert/SKILL.md +++ b/.agents/skills/pretty-console-expert/SKILL.md @@ -46,8 +46,9 @@ using static System.Console; // optional - Prefer interpolated-handler APIs over manually concatenated strings. - Avoid span/formattable `Write`/`WriteLine` overloads in normal app code; reserve them for rare advanced/manual formatting scenarios. -- If the intent is only to end the current line or emit a blank line, use `Console.NewLine(pipe)` instead of `WriteLineInterpolated($"")` or reset-only interpolations such as `$"{ConsoleColor.Default}"`. +- If the intent is only to end the current line or emit a blank line, use `Console.NewLine(pipe)` instead of `WriteLineInterpolated($"")` or reset-only interpolations such as `$"{Color.Default}"`. - Keep ANSI/decorations inside interpolation holes (for example, `$"{Markup.Bold}..."`) instead of literal escape codes inside string literals. +- Prefer `Color`, `Markup`, and guarded `AnsiToken` in interpolated output. Keep `ConsoleColor` for APIs that explicitly require it (`ProgressBar`, `Spinner`, low-level span writes, `Console.SetColors`, `TypeWrite`, etc.). - Route transient UI (spinner/progress/overwrite loops) to `OutputPipe.Error` to keep stdout pipe-friendly, and use `OutputPipe.Error` for genuine errors/diagnostics. Keep ordinary non-error interaction flow on `OutputPipe.Out`. - Spinner/progress/overwrite output is caller-owned after rendering completes. Explicitly remove it with `Console.ClearNextLines(totalLines, pipe)` or intentionally keep the region with `Console.SkipLines(totalLines)`. - `LiveConsoleRegion` is the right primitive when durable line output and transient status must interleave over time. It is line-oriented: use `WriteLine`, not inline writes, for cooperating durable output above the retained region. @@ -76,7 +77,7 @@ using static System.Console; // optional - Use `ProgressBar.Render(...)`, not `ProgressBar.WriteProgressBar(...)`. - Use `LiveConsoleRegion` for retained live regions; do not approximate that behavior with ad-hoc `Overwrite` loops when durable writes must keep streaming around the live output. - Use `ConsoleContext`, not `PrettyConsoleExtensions`. -- Use `ConsoleColor` helpers/tuples (for example `ConsoleColor.Red / ConsoleColor.White`), not removed `ColoredOutput`/`Color` types. +- Use `Color`/`Markup`/`AnsiToken` for interpolated styling. Use `ConsoleColor` only when the API explicitly requires it. - Use `Console.NewLine(pipe)` when you only need a newline or blank line; do not use `WriteLineInterpolated` with empty/reset-only payloads just to move the cursor. - Use `Confirm(ReadOnlySpan trueValues, ref PrettyConsoleInterpolatedStringHandler handler, bool emptyIsTrue = true)` (boolean parameter is last). - Use handler factory overloads for dynamic spinner/progress headers: @@ -86,11 +87,11 @@ using static System.Console; // optional ```csharp // Colored/status output -Console.WriteLineInterpolated($"{ConsoleColor.Green / ConsoleColor.DefaultBackground}OK{ConsoleColor.Default}"); +Console.WriteLineInterpolated($"{Color.Green}OK{Color.Default}"); Console.NewLine(); // Typed input -if (!Console.TryReadLine(out int port, $"Port ({ConsoleColor.Cyan}5000{ConsoleColor.Default}): ")) +if (!Console.TryReadLine(out int port, $"Port ({Color.Cyan}5000{Color.Default}): ")) port = 5000; // Confirm with custom truthy tokens diff --git a/AGENTS.md b/AGENTS.md index bbe19b5..c56526b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Summary - PrettyConsole.Tests/ — interactive/demo runner (manually selects visual feature demos) - PrettyConsole.UnitTests/ — TUnit-based automated tests run with `dotnet run` - Examples/ — standalone `.cs` sample apps plus `assets/` previews; documented in `Examples/README.md` and excluded from automated builds/tests -- v5.5.0 (current) adds `LiveConsoleRegion`, a retained single-owner live region for Cargo-style durable status streaming plus a pinned transient line on one `OutputPipe`. It exposes line-oriented `WriteLine`, generic `Render`, region-owned `RenderProgress`, `Clear`, and `Dispose`, and the interpolated string handler now has a constructor overload that binds interpolation directly to a `LiveConsoleRegion` instance. v5.4.0 renamed `IndeterminateProgressBar` to `Spinner` (and `AnimationSequence` to `Pattern`), triggers the line reset at the start of each spinner frame, gives all `RunAsync` overloads default cancellation tokens, and renames `ProgressBar.WriteProgressBar` to `Render` while adding handler-factory overloads and switching header parameters to `string`. It still passes handlers by `ref`, adds `AppendInline`, and introduces the ctor that takes only `OutputPipe` + optional `IFormatProvider`; `SkipLines` advances the cursor while keeping overwritten UIs; `Confirm(trueValues, ref handler, bool emptyIsTrue = true)` has the boolean last; spinner header factories use `PrettyConsoleInterpolatedStringHandlerFactory` with the singleton builder; `AnsiColors` is public. v5.2.0 rewrote the handler to buffer before writing and added `WhiteSpace`; v5.1.0 renamed `PrettyConsoleExtensions` to `ConsoleContext`, added `Console.WriteWhiteSpaces(length, pipe)`, and made `Out`/`Error`/`In` settable; v5.0.0 removed the legacy `ColoredOutput`/`Color` types in favor of `ConsoleColor` helpers and tuples. +- v6.0.0 (current) follows released `v5.4.2` directly and introduces guarded ANSI tokens as the primary interpolation-facing styling model, adds `LiveConsoleRegion`, and adds `ConsoleContext.IsAnsiSupported` for platform-sensitive ANSI capability detection (including Windows VT checks). `Color` now provides cached `AnsiToken` values for foreground/background/default colors, `Markup` exposes guarded decoration tokens, `AnsiColors` maps `ConsoleColor` into that token cache, and `PrettyConsoleInterpolatedStringHandler` resets through `Color.Default`. `ConsoleColor` interpolation remains supported for compatibility while `ProgressBar`, `Spinner`, and other explicit color-taking APIs still use `ConsoleColor`. v5.4.0 renamed `IndeterminateProgressBar` to `Spinner` (and `AnimationSequence` to `Pattern`), triggers the line reset at the start of each spinner frame, gives all `RunAsync` overloads default cancellation tokens, and renames `ProgressBar.WriteProgressBar` to `Render` while adding handler-factory overloads and switching header parameters to `string`. It still passes handlers by `ref`, adds `AppendInline`, and introduces the ctor that takes only `OutputPipe` + optional `IFormatProvider`; `SkipLines` advances the cursor while keeping overwritten UIs; `Confirm(trueValues, ref handler, bool emptyIsTrue = true)` has the boolean last; spinner header factories use `PrettyConsoleInterpolatedStringHandlerFactory` with the singleton builder; `AnsiColors` is public. v5.2.0 rewrote the handler to buffer before writing and added `WhiteSpace`; v5.1.0 renamed `PrettyConsoleExtensions` to `ConsoleContext`, added `Console.WriteWhiteSpaces(length, pipe)`, and made `Out`/`Error`/`In` settable; v5.0.0 removed the legacy `ColoredOutput`/`Color` types in favor of `ConsoleColor` helpers and tuples. Commands you’ll use often @@ -35,7 +35,7 @@ Repo-specific agent rules and conventions - When changing a specific project, build/run just that project to validate, not the entire solution. - For tests in `PrettyConsole.UnitTests`, use dotnet run, never dotnet test. - Treat `PrettyConsoleInterpolatedStringHandler` as the leading output abstraction in the library. Do not modify the handler to fit secondary APIs unless the user explicitly asks for handler changes; instead, realign other APIs to compose through the existing handler-facing APIs and semantics. -- When unifying colored output paths, prefer composing through `WriteInterpolated`/`WriteLineInterpolated` and existing `ConsoleColor` tuple interpolation rather than adding new handler hooks or duplicating ANSI/color-state logic elsewhere. +- When unifying colored output paths, prefer composing through `WriteInterpolated`/`WriteLineInterpolated` and the existing `Color`/`Markup`/`AnsiToken` interpolation model rather than adding new handler hooks or duplicating ANSI logic elsewhere. - If the requested direction may already exist in the worktree, inspect staged changes before designing the implementation so you follow the repository's intended approach instead of inventing a parallel one. - Adhere to .editorconfig in the repo for style and analyzers. - If code needs to be “removed” as part of a change, do not delete files; comment out their contents so they won’t compile. @@ -50,11 +50,14 @@ High-level architecture and key concepts - Interpolated string handler - `PrettyConsoleInterpolatedStringHandler` buffers interpolated content before emitting it, stays allocation-free, now exposes additional public helpers (including `AppendInline` for composing handlers) and is constructed/consumed by `ref`. `$"..."` calls light up `WriteInterpolated`, `WriteLineInterpolated`, `ReadLine`, `TryReadLine`, `Confirm`, and `RequestAnyInput`. Colors auto-reset, handlers respect the selected pipe/`IFormatProvider`, and `object` arguments that implement `ISpanFormattable` are emitted via the span path before falling back to `IFormattable`/string. `Console.WriteInterpolated`/`WriteLineInterpolated` return the rendered character count (handler-emitted escape sequences excluded). Passing the `WhiteSpace` struct writes padding directly from the handler without allocations. - The handler has the highest optimization and stability priority in the package. Preserve its behavior and shape unless handler work is the explicit task; changes elsewhere should conform to the handler, not force the handler to accommodate them. - - Mid-span ANSI sequences are intentionally unsupported: every ANSI sequence (from `ConsoleColor` conversions or `Markup`) is only safe when emitted via an interpolated hole, which lets the handler isolate the escape and keep width calculations consistent. Do not try to "account" for mid-span sequences or adjust character counts manually when discussing this repo. + - Mid-span ANSI sequences are intentionally unsupported: every ANSI sequence (from `Color`, `Markup`, guarded `AnsiToken`, or `ConsoleColor` conversions) is only safe when emitted via an interpolated hole, which lets the handler isolate the escape and keep width calculations consistent. Do not try to "account" for mid-span sequences or adjust character counts manually when discussing this repo. - Coloring model - - `ConsoleColor` 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. `AnsiColors` is now public if you need raw ANSI sequences from `ConsoleColor`. + - `Color` is the preferred handler-facing color API. It exposes cached guarded `AnsiToken`s such as `Color.Green`, `Color.GreenBackground`, `Color.DefaultForeground`, `Color.DefaultBackground`, and `Color.Default`. + - `AnsiToken` is the guarded ANSI abstraction used by the interpolated string handler. Use `new AnsiToken("...")` for custom guarded ANSI holes. + - `ConsoleColor` interpolation remains supported for compatibility, but explicit `ConsoleColor` APIs are now primarily for low-level writes and components like `ProgressBar`, `Spinner`, `Console.SetColors`, and `TypeWrite`. + - `AnsiColors` maps `ConsoleColor` values into the cached `Color` token surface (`AnsiColors.Foreground(ConsoleColor.Green)` and `Color.Green` are the same token). - 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. + - The `Markup` static class exposes guarded `AnsiToken`s for underline, bold, italic, and strikethrough. These are suppressed by the handler when output is redirected or ANSI is unsupported. `Markup.Reset` resets both decorations and colors. - Write APIs - `WriteInterpolated`/`WriteLineInterpolated` are the default output APIs and host the interpolated-string handler; this path already covers high-performance formatting and coloring. Keep `Write`/`WriteLine` overloads (`ISpanFormattable`/`ReadOnlySpan`) for rare low-level scenarios where callers intentionally bypass the handler with custom formatting pipelines. Those overloads still rent buffers from `ArrayPool.Shared` and reset colors. - If these low-level overloads need to be brought back into alignment with the main output model, prefer delegating to the existing interpolated APIs rather than changing handler internals to preserve legacy low-level behavior. @@ -87,9 +90,9 @@ Testing structure and workflows Notes and gotchas -- The library aims to minimize allocations; for normal app-level output prefer interpolated-handler APIs (`WriteInterpolated`/`WriteLineInterpolated`) plus inline `ConsoleColor` tuples. Use span-based `Write`/`WriteLine` overloads only for rare low-level formatting bypass scenarios. +- The library aims to minimize allocations; for normal app-level output prefer interpolated-handler APIs (`WriteInterpolated`/`WriteLineInterpolated`) plus inline `Color`/`Markup`/`AnsiToken` holes. Use span-based `Write`/`WriteLine` overloads only for rare low-level formatting bypass scenarios. - 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. +- On macOS terminals, ANSI is supported; on Windows, handler-emitted ANSI is gated by `ConsoleContext.IsAnsiSupported` so unsupported VT environments fall back to plain text for guarded ANSI paths. - `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.Render` renders one-off bars without writing a trailing newline, so rely on `Console.Overwrite`/`lines` to stack multiple bars cleanly. - `LiveConsoleRegion` is line-oriented by design: it restores after `WriteLine`, not after arbitrary inline text, and it owns only one pipe. Default to `OutputPipe.Error` for interactive status UI so stdout stays pipe-friendly. - After the final `Overwrite`/`Overwrite` call in a rendering loop, call `Console.ClearNextLines(totalLines, pipe)` once more to clear the region and prevent ghost text. diff --git a/PrettyConsole.UnitTests/AdvancedOutputsTests.cs b/PrettyConsole.UnitTests/AdvancedOutputsTests.cs index 41cfc02..6a52adf 100755 --- a/PrettyConsole.UnitTests/AdvancedOutputsTests.cs +++ b/PrettyConsole.UnitTests/AdvancedOutputsTests.cs @@ -42,14 +42,14 @@ public async Task Overwrite_WithState_ExecutesActionAndWritesOutput() { [Test] public async Task TypeWrite_Regular() { Out = Utilities.GetWriter(out var stringWriter); - await Console.TypeWrite("Hello world!", Green / Black, 10); + await Console.TypeWrite("Hello world!", (Color.Green, Color.BlackBackground), 10); await Assert.That(stringWriter.ToString()).Contains("Hello world!"); } [Test] public async Task TypeWriteLine_Regular() { Out = Utilities.GetWriter(out var stringWriter); - await Console.TypeWriteLine("Hello world!", Green / ConsoleColor.Default, 10); + await Console.TypeWriteLine("Hello world!", (Color.Green, Color.DefaultBackground), 10); await Assert.That(stringWriter.ToString()).Contains("Hello world!" + Environment.NewLine); } -} \ No newline at end of file +} diff --git a/PrettyConsole.UnitTests/ConsoleColorTests.cs b/PrettyConsole.UnitTests/ConsoleColorTests.cs index e0d9f92..b28276c 100644 --- a/PrettyConsole.UnitTests/ConsoleColorTests.cs +++ b/PrettyConsole.UnitTests/ConsoleColorTests.cs @@ -95,6 +95,12 @@ public async Task Color_BuiltInTokens_AliasAnsiColorsCache() { await Assert.That(Color.GreenBackground).IsSameReferenceAs(AnsiColors.Background(Green)); } + [Test] + public async Task ConsoleColor_ImplicitlyConvertsToForegroundAnsiToken() { + AnsiToken token = ConsoleColor.Green; + await Assert.That(token).IsSameReferenceAs(Color.Green); + } + [Test] public async Task AnsiColors_IndexOrder_MatchesConsoleColorEnumOrder() { AnsiToken[] expectedForeground = [ diff --git a/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs b/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs index 7a2e786..4c72cb3 100644 --- a/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs +++ b/PrettyConsole.UnitTests/LiveConsoleRegionTests.cs @@ -76,7 +76,7 @@ public async Task RenderProgress_WithFactory_SameLine_WritesHeaderAndBar() { Error = Utilities.GetWriter(out var errorWriter); using var region = new LiveConsoleRegion(); - region.RenderProgress(40, (builder, out handler) => handler = builder.Build(OutputPipe.Error, $"hdr"), sameLine: true, progressColor: Cyan); + region.RenderProgress(40, (builder, out handler) => handler = builder.Build(OutputPipe.Error, $"hdr"), sameLine: true, progressColor: Color.Cyan); var output = Utilities.StripAnsiSequences(errorWriter.ToString()); await Assert.That(output).Contains("hdr"); @@ -97,7 +97,7 @@ public async Task RenderProgress_WithFactory_TwoLines_WritesHeaderAboveBar() { Error = Utilities.GetWriter(out var errorWriter); using var region = new LiveConsoleRegion(); - region.RenderProgress(55, (builder, out handler) => handler = builder.Build(OutputPipe.Error, $"status"), sameLine: false, progressColor: Cyan); + region.RenderProgress(55, (builder, out handler) => handler = builder.Build(OutputPipe.Error, $"status"), sameLine: false, progressColor: Color.Cyan); var output = Utilities.StripAnsiSequences(errorWriter.ToString()); await Assert.That(output).Contains("status"); diff --git a/PrettyConsole.UnitTests/ProgressBarTests.cs b/PrettyConsole.UnitTests/ProgressBarTests.cs index 8561b7c..7875b2c 100644 --- a/PrettyConsole.UnitTests/ProgressBarTests.cs +++ b/PrettyConsole.UnitTests/ProgressBarTests.cs @@ -10,8 +10,8 @@ public async Task ProgressBar_Update_WritesStatusAndPercentage() { try { var bar = new ProgressBar { ProgressChar = '#', - ForegroundColor = White, - ProgressColor = Green + ForegroundColor = Color.White, + ProgressColor = Color.Green }; bar.Update(50, "Loading"); @@ -33,8 +33,8 @@ public async Task ProgressBar_Update_SamePercentage_RerendersOutput() { try { var bar = new ProgressBar { ProgressChar = '#', - ForegroundColor = White, - ProgressColor = Green + ForegroundColor = Color.White, + ProgressColor = Color.Green }; bar.Update(25, "Loading"); @@ -124,7 +124,7 @@ public async Task ProgressBar_Render_WritesFormattedOutput() { try { Out = Utilities.GetWriter(out var outWriter); - ProgressBar.Render(OutputPipe.Out, 75, Cyan, '*'); + ProgressBar.Render(OutputPipe.Out, 75, Color.Cyan, '*'); var output = outWriter.ToString(); await Assert.That(output).Contains("["); @@ -141,7 +141,7 @@ public async Task ProgressBar_Render_RespectsMaxLineWidth() { try { Out = Utilities.GetWriter(out var outWriter); - ProgressBar.Render(OutputPipe.Out, 50, Cyan, '*', maxLineWidth: 24); + ProgressBar.Render(OutputPipe.Out, 50, Color.Cyan, '*', maxLineWidth: 24); var output = outWriter.ToString(); await Assert.That(output.Length).IsEqualTo(24); @@ -278,7 +278,7 @@ public async Task Spinner_RunAsync_OverloadsAndForegroundSetter() { var spinner = new Spinner { DisplayElapsedTime = false, UpdateRate = 5, - ForegroundColor = Cyan + ForegroundColor = Color.Cyan }; var genericResult = await spinner.RunAsync(Task.Run(async () => { await Task.Delay(10); return 7; })); @@ -386,4 +386,4 @@ public static bool ColorsSupported() { Console.BackgroundColor = originalBackground; } } -} \ No newline at end of file +} diff --git a/PrettyConsole/AdvancedOutputExtensions.cs b/PrettyConsole/AdvancedOutputExtensions.cs index 306418d..b977d75 100755 --- a/PrettyConsole/AdvancedOutputExtensions.cs +++ b/PrettyConsole/AdvancedOutputExtensions.cs @@ -47,22 +47,43 @@ public static void Overwrite(TState state, Action action, int li /// /// /// Delay in milliseconds between each character. - public static async Task TypeWrite(string output, (ConsoleColor foregroundColor, ConsoleColor backgroundColor) colorTuple, int delay = TypeWriteDefaultDelay) { + public static async Task TypeWrite(string output, (AnsiToken foregroundColor, AnsiToken backgroundColor) colorTuple, int delay = TypeWriteDefaultDelay) { foreach (char c in output) { - Console.Write(c, OutputPipe.Out, colorTuple.foregroundColor, colorTuple.backgroundColor); + Console.WriteInterpolated($"{colorTuple.foregroundColor}{colorTuple.backgroundColor}{c}"); await Task.Delay(delay); } } + /// + /// Types out character by character with a delay of milliseconds between each character, styled using . + /// + /// + /// + /// Delay in milliseconds between each character. + public static async Task TypeWrite(string output, (ConsoleColor foregroundColor, ConsoleColor backgroundColor) colorTuple, int delay = TypeWriteDefaultDelay) { + await TypeWrite(output, (AnsiColors.Foreground(colorTuple.foregroundColor), AnsiColors.Background(colorTuple.backgroundColor)), delay); + } + /// /// Types out character by character with a delay of milliseconds between each character, styled using followed by a line terminator. /// /// /// /// Delay in milliseconds between each character. - public static async Task TypeWriteLine(string output, (ConsoleColor foregroundColor, ConsoleColor backgroundColor) colorTuple, int delay = TypeWriteDefaultDelay) { + public static async Task TypeWriteLine(string output, (AnsiToken foregroundColor, AnsiToken backgroundColor) colorTuple, int delay = TypeWriteDefaultDelay) { await TypeWrite(output, colorTuple, delay); Console.NewLine(); } + + /// + /// Types out character by character with a delay of milliseconds between each character, styled using followed by a line terminator. + /// + /// + /// + /// Delay in milliseconds between each character. + public static async Task TypeWriteLine(string output, (ConsoleColor foregroundColor, ConsoleColor backgroundColor) colorTuple, int delay = TypeWriteDefaultDelay) { + await TypeWrite(output, (AnsiColors.Foreground(colorTuple.foregroundColor), AnsiColors.Background(colorTuple.backgroundColor)), delay); + Console.NewLine(); + } } -} \ No newline at end of file +} diff --git a/PrettyConsole/AnsiToken.cs b/PrettyConsole/AnsiToken.cs index a30f492..6ca6afb 100644 --- a/PrettyConsole/AnsiToken.cs +++ b/PrettyConsole/AnsiToken.cs @@ -4,4 +4,9 @@ namespace PrettyConsole; /// Represents a guarded ANSI token that emits its escape sequence through . /// /// The ANSI sequence -public record AnsiToken(string Value); +public record AnsiToken(string Value) { + /// + /// Converts a to its corresponding foreground ANSI token. + /// + public static implicit operator AnsiToken(ConsoleColor color) => AnsiColors.Foreground(color); +} diff --git a/PrettyConsole/LiveConsoleRegion.cs b/PrettyConsole/LiveConsoleRegion.cs index 648c125..85a73bf 100644 --- a/PrettyConsole/LiveConsoleRegion.cs +++ b/PrettyConsole/LiveConsoleRegion.cs @@ -3,7 +3,7 @@ namespace PrettyConsole; /// -/// Owns a transient console region on a single output pipe and coordinates it with durable writes. +/// Manages a retained transient console region on a single output pipe while coordinating durable writes above it. /// public sealed class LiveConsoleRegion : IDisposable { private const int InitialSnapshotCapacity = 256; @@ -31,15 +31,17 @@ public sealed class LiveConsoleRegion : IDisposable { public int OccupiedLines { get; private set; } /// - /// Creates a new region bound to . + /// Creates a new live region bound to the specified output pipe. /// + /// The output pipe used for both retained transient content and durable writes. public LiveConsoleRegion(OutputPipe pipe = OutputPipe.Error) { _pipe = pipe; } /// - /// Writes durable output followed by a newline and restores the transient region afterwards. + /// Writes a durable line above the retained region and then restores the region beneath it. /// + /// The interpolated content to write as a durable line. public void WriteLine([InterpolatedStringHandlerArgument("")] ref PrettyConsoleInterpolatedStringHandler handler) { lock (_lock) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -59,8 +61,9 @@ public void WriteLine([InterpolatedStringHandlerArgument("")] ref PrettyConsoleI } /// - /// Replaces the transient region contents with the rendered handler output. + /// Replaces the current retained region contents with the rendered handler output. /// + /// The interpolated content to retain as the region snapshot. public void Render([InterpolatedStringHandlerArgument("")] ref PrettyConsoleInterpolatedStringHandler handler) { lock (_lock) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -94,13 +97,19 @@ public void Render([InterpolatedStringHandlerArgument("")] ref PrettyConsoleInte } /// - /// Renders a progress bar snapshot into this region, optionally prefixed with handler-built content. + /// Renders a retained progress-bar snapshot into the live region, optionally prefixed with header content. /// + /// The progress percentage to render. + /// Optional header factory that renders content before the progress bar. + /// Whether the optional header should share the same line as the progress bar. + /// Optional color token for the filled progress segment. + /// The character used for the filled portion of the progress bar. + /// Optional total width constraint for the rendered progress line. public void RenderProgress( double percentage, PrettyConsoleInterpolatedStringHandlerFactory? factory = null, bool sameLine = true, - ConsoleColor? progressColor = null, + AnsiToken? progressColor = null, char progressChar = ProgressBar.DefaultProgressChar, int? maxLineWidth = null) { var handler = new PrettyConsoleInterpolatedStringHandler(_pipe); @@ -118,12 +127,12 @@ public void RenderProgress( } } - ProgressBar.AppendTo(ref handler, (int)percentage, progressColor ?? ConsoleColor.DefaultForeground, cursorLeft, progressChar, maxLineWidth); + ProgressBar.AppendTo(ref handler, (int)percentage, progressColor ?? Color.DefaultForeground, cursorLeft, progressChar, maxLineWidth); Render(ref handler); } /// - /// Clears the transient region and forgets any retained snapshot. + /// Clears the retained region from the console and discards the current snapshot. /// public void Clear() { lock (_lock) { diff --git a/PrettyConsole/PrettyConsole.csproj b/PrettyConsole/PrettyConsole.csproj index 9948fd2..42347fe 100755 --- a/PrettyConsole/PrettyConsole.csproj +++ b/PrettyConsole/PrettyConsole.csproj @@ -18,15 +18,14 @@ David Shnayder PrettyConsole - High performance, ultra-low-latency, allocation-free, feature rich and easy to use - wrap over System.Console + High performance, ultra-low-latency, allocation-free extension layer over System.Console with guarded ANSI tokens, structured rendering, and rich CLI helpers David Shnayder Console; Output; Input; ANSI enable https://github.com/dusrdev/PrettyConsole/ https://github.com/dusrdev/PrettyConsole/ git - 5.5.0 + 6.0.0 enable true MIT diff --git a/PrettyConsole/ProgressBar.cs b/PrettyConsole/ProgressBar.cs index 853d17f..3866535 100755 --- a/PrettyConsole/ProgressBar.cs +++ b/PrettyConsole/ProgressBar.cs @@ -25,12 +25,12 @@ public class ProgressBar { /// /// Gets or sets the foreground color of the status (if rendered). /// - public ConsoleColor ForegroundColor { get; set; } = ConsoleColor.DefaultForeground; + public AnsiToken ForegroundColor { get; set; } = Color.DefaultForeground; /// /// Gets or sets the color of the progress portion of the bar. /// - public ConsoleColor ProgressColor { get; set; } = ConsoleColor.DefaultForeground; + public AnsiToken ProgressColor { get; set; } = Color.DefaultForeground; /// /// Gets or sets an optional total width for the rendered bar line (includes brackets, spacing, and percentage). @@ -135,10 +135,10 @@ public void Update(int percentage, PrettyConsoleInterpolatedStringHandlerFactory /// /// The output pipe to write to. /// The percentage value (0-100) representing the progress. - /// The color used for the filled segment of the bar. + /// The color token used for the filled segment of the bar. /// The character used to render the filled portion of the bar. /// Optional total line length (including brackets and percentage). When provided, the rendered output will not exceed this width unless the decorations already require more characters. - public static void Render(OutputPipe pipe, double percentage, ConsoleColor progressColor, char progressChar = DefaultProgressChar, int? maxLineWidth = null) + public static void Render(OutputPipe pipe, double percentage, AnsiToken progressColor, char progressChar = DefaultProgressChar, int? maxLineWidth = null) => Render(pipe, (int)percentage, progressColor, progressChar, maxLineWidth); /// @@ -146,10 +146,10 @@ public static void Render(OutputPipe pipe, double percentage, ConsoleColor progr /// /// The output pipe to write to. /// The percentage value (0-100) representing the progress. - /// The color used for the filled segment of the bar. + /// The color token used for the filled segment of the bar. /// The character used to render the filled portion of the bar. /// Optional total line length (including brackets and percentage). When provided, the rendered output will not exceed this width unless the decorations already require more characters. - public static void Render(OutputPipe pipe, int percentage, ConsoleColor progressColor, char progressChar = DefaultProgressChar, int? maxLineWidth = null) { + public static void Render(OutputPipe pipe, int percentage, AnsiToken progressColor, char progressChar = DefaultProgressChar, int? maxLineWidth = null) { var handler = new PrettyConsoleInterpolatedStringHandler(pipe); AppendTo(ref handler, percentage, progressColor, Console.CursorLeft, progressChar, maxLineWidth); handler.Flush(); @@ -158,7 +158,7 @@ public static void Render(OutputPipe pipe, int percentage, ConsoleColor progress internal static void AppendTo( ref PrettyConsoleInterpolatedStringHandler handler, int percentage, - ConsoleColor progressColor, + AnsiToken progressColor, int cursorLeft, char progressChar = DefaultProgressChar, int? maxLineWidth = null) { @@ -189,7 +189,7 @@ internal static void AppendTo( if (filled > 0) { handler.AppendFormatted(progress); } - handler.AppendFormatted(ConsoleColor.DefaultForeground); + handler.AppendFormatted(Color.DefaultForeground); if (remaining > 0) { handler.AppendFormatted(new WhiteSpace(remaining)); } diff --git a/PrettyConsole/Spinner.cs b/PrettyConsole/Spinner.cs index 36518ce..bd914b6 100755 --- a/PrettyConsole/Spinner.cs +++ b/PrettyConsole/Spinner.cs @@ -24,7 +24,7 @@ public class Spinner { /// /// Gets or sets the foreground color of the spinner. /// - public ConsoleColor ForegroundColor { get; set; } = ConsoleColor.DefaultForeground; + public AnsiToken ForegroundColor { get; set; } = Color.DefaultForeground; /// /// Gets or sets a value indicating whether to display the elapsed time next to the spinner. @@ -131,7 +131,7 @@ private async Task RunAsyncNonGeneric(Task task, PrettyConsoleInterpolatedString while (!task.IsCompleted && !token.IsCancellationRequested) { Console.ClearNextLines(1, OutputPipe.Error); // Clear at start to prevent auto-delete after last write - Console.WriteInterpolated(OutputPipe.Error, $"{ForegroundColor}{Pattern[seqIndex]}{ConsoleColor.DefaultForeground}"); + Console.WriteInterpolated(OutputPipe.Error, $"{ForegroundColor}{Pattern[seqIndex]}"); if (headerFactory is not null) { ConsoleContext.Error.WriteWhiteSpaces(1); @@ -232,4 +232,4 @@ public static readonly ReadOnlyCollection PingPong "| • |", ]); } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 66aa29e..4e8be4e 100755 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ PrettyConsole is a high-performance, ultra-low-latency, allocation-free extensio ## Features - 🚀 Zero-allocation interpolated string handler (`PrettyConsoleInterpolatedStringHandler`) for inline colors and formatting -- 🎨 Inline color composition with `ConsoleColor` tuples and helpers (`DefaultForeground`, `DefaultBackground`, `Default`) plus `AnsiColors` utilities when you need raw ANSI sequences +- 🎨 Guarded ANSI tokens for interpolation via `Color`, `Markup`, and custom `AnsiToken`, with `ConsoleColor` compatibility for APIs that explicitly require it - 🔁 Advanced rendering primitives (`Overwrite`, `ClearNextLines`, `GoToLine`, `SkipLines`, progress bars) that respect console pipes - 📌 `LiveConsoleRegion` for a retained live line/region that stays pinned while durable status lines stream above it on the same pipe - 🧱 Handler-aware `WhiteSpace` struct for zero-allocation padding directly inside interpolated strings @@ -70,41 +70,44 @@ Standalone samples made with .NET 10 file-based apps with preview clips are avai ```csharp using PrettyConsole; // Extension members + OutputPipe using static System.Console; // Optional for terser call sites +using static PrettyConsole.Color; // Optional for terser color tokens ``` This setup lets you call `Console.WriteInterpolated`, `Console.Overwrite`, `Console.TryReadLine`, etc. The original `System.Console` APIs remain available—call `System.Console.ReadKey()` or `System.Console.SetCursorPosition()` directly whenever you need something the extensions do not provide. ### Interpolated strings & inline colors -`PrettyConsoleInterpolatedStringHandler` now buffers interpolated content in a pooled buffer before flushing to the selected pipe. Colors auto-reset at the end of each call. `Console.WriteInterpolated` and `Console.WriteLineInterpolated` return the number of visible characters written (handler-emitted escape sequences are excluded) so you can drive padding/width calculations from the same call sites. +`Console.WriteInterpolated` and `Console.WriteLineInterpolated` are the main output APIs for styled text. Colors reset automatically at the end of each call, and both methods return the number of visible characters written so you can reuse the result in padding or layout calculations. + +For interpolation, prefer `Color` and `Markup`. `ConsoleColor` interpolation still works, but `Color` is the primary public surface for guarded styled output. ```csharp -Console.WriteInterpolated($"Hello {ConsoleColor.Green / ConsoleColor.DefaultBackground}world{ConsoleColor.Default}!"); -Console.WriteInterpolated(OutputPipe.Error, $"{ConsoleColor.Yellow / ConsoleColor.DefaultBackground}warning{ConsoleColor.Default}: {message}"); +Console.WriteInterpolated($"Hello {Green}world{Default}!"); +Console.WriteInterpolated(OutputPipe.Error, $"{Yellow}warning{Default}: {message}"); -if (!Console.TryReadLine(out int choice, $"Pick option {ConsoleColor.Cyan / ConsoleColor.DefaultBackground}1-5{ConsoleColor.Default}: ")) { - Console.WriteLineInterpolated($"{ConsoleColor.Red / ConsoleColor.DefaultBackground}Not a number.{ConsoleColor.Default}"); +if (!Console.TryReadLine(out int choice, $"Pick option {Cyan}1-5{Default}: ")) { + Console.WriteLineInterpolated($"{Red}Not a number.{Default}"); } // Zero-allocation padding directly from the handler Console.WriteInterpolated($"Header{new WhiteSpace(6)}Value"); ``` -`ConsoleColor.DefaultForeground`, `ConsoleColor.DefaultBackground`, and the `/` operator overload make it easy to compose foreground/background tuples inline (`ConsoleColor.Red / ConsoleColor.White`). +`Color` exposes tokens for both foreground and background colors (`Green`, `GreenBackground`, `DefaultForeground`, `DefaultBackground`, `Default`). `AnsiColors` is available when you want to convert an existing `ConsoleColor` value into the same style of token, while APIs like `ProgressBar` and span-based `Write`/`WriteLine` still take `ConsoleColor` directly. #### Inline decorations via `Markup` -When ANSI escape sequences are safe to emit (`Console.IsOutputRedirected`/`IsErrorRedirected` are both `false`), the `Markup` helper exposes ready-to-use toggles for underline, bold, italic, and strikethrough: +`Markup` exposes ready-to-use guarded tokens for underline, bold, italic, and strikethrough: ```csharp Console.WriteLineInterpolated($"{Markup.Bold}Build{Markup.ResetBold} {Markup.Underline}completed{Markup.ResetUnderline} in {elapsed:duration}"); // e.g. "completed in 2h 3m 17s" ``` -All fields collapse to `string.Empty` when markup is disabled, so the same call sites continue to work when output is redirected or the terminal ignores decorations. Use `Markup.Reset` if you want to reset every decoration at once. +Use `Markup.Reset` if you want to reset every decoration and color at once. #### Formatting & alignment helpers -- **`TimeSpan :duration` format** — the interpolated string handler understands the custom `:duration` specifier. It emits integer `hours`/`minutes`/`seconds` tokens (e.g., `5h 32m 12s`, `27h 12m 3s`, `123h 0m 0s`) without allocations, and the hour component keeps growing past 24 so long-running tasks stay accurate. Minutes/seconds are not zero-padded so the output stays compact: +- **`TimeSpan :duration` format** — use the custom `:duration` specifier to render values like `5h 32m 12s`, `27h 12m 3s`, or `123h 0m 0s`. The hour component keeps growing past 24 so long-running tasks stay accurate, and minutes/seconds stay compact: ```csharp var elapsed = stopwatch.Elapsed; @@ -119,7 +122,7 @@ All fields collapse to `string.Empty` when markup is disabled, so the same call Console.WriteInterpolated($"Remaining {remaining,8:bytes}"); // right-aligned units stay tidy ``` -- **Alignment** — standard alignment syntax works the same way it does with regular interpolated strings, but the handler writes directly into the console buffer. This keeps columnar output zero-allocation friendly: +- **Alignment** — standard alignment syntax works the same way it does with regular interpolated strings, so columnar console output stays straightforward: ```csharp Console.WriteInterpolated($"|{"Label",-10}|{value,10:0.00}|"); @@ -127,48 +130,31 @@ All fields collapse to `string.Empty` when markup is disabled, so the same call You can combine both, e.g., `$"{elapsed,8:duration}"`, to keep progress/status displays tidy. -- **`WhiteSpace` struct for padding** — pass `new WhiteSpace(length)` inside an interpolated string to emit that many spaces straight from the handler without allocating intermediate strings. +- **`WhiteSpace` struct for padding** — pass `new WhiteSpace(length)` inside an interpolated string when you want explicit padding without building a separate string first. -- **Custom escape sequences** — if you need your own ANSI code (extra markup/colors), keep it in an interpolated hole instead of hardcoding it into the literal so the handler can treat it like other escape spans: +- **Custom ANSI tokens** — if you need your own ANSI code for extra markup or color, wrap it in `AnsiToken` and keep it in an interpolated hole: ```csharp -var rose = "\u001b[38;5;213m"; // custom 256-color escape -Console.WriteInterpolated($"{rose}accent text{Markup.Reset}"); +var rose = new AnsiToken("\u001b[38;5;213m"); // custom 256-color escape +Console.WriteInterpolated($"{rose}accent text{Color.Default}"); ``` -Avoid embedding the escape directly in the literal (`"\u001b[38;5;213maccent text"`), which would be measured as visible width and could skew padding/alignment. +Avoid embedding the escape directly in the literal (`"\u001b[38;5;213maccent text"`), which can interfere with width-sensitive output. If you want PrettyConsole to handle ANSI safely for you, use `Color`, `Markup`, or `AnsiToken`. ### Basic outputs ```csharp // Interpolated text Console.WriteInterpolated($"Processed {items} items in {elapsed:duration}"); // Processed 42 items in 3h 44m 9s -Console.WriteLineInterpolated(OutputPipe.Error, $"{ConsoleColor.Magenta}debug{ConsoleColor.Default}"); +Console.WriteLineInterpolated(OutputPipe.Error, $"{Magenta}debug{Default}"); ``` -`WriteInterpolated` / `WriteLineInterpolated` should be your default output path. The handler already applies the high-performance formatting path internally and supports inline colors/alignment/specifiers. - -### Low-level output escape hatch (rare) - -Use these only when you intentionally bypass the interpolated handler and own formatting end-to-end (advanced/manual pipelines): - -```csharp -// Span + color overloads (no boxing) -ReadOnlySpan header = "Title"; -Console.Write(header, OutputPipe.Error, ConsoleColor.White, ConsoleColor.DarkBlue); -Console.NewLine(OutputPipe.Error); - -// ISpanFormattable (works with ref structs) -Console.Write(percentage); // uses the default output pipe -Console.Write(percentage, OutputPipe.Error, ConsoleColor.Cyan, ConsoleColor.DefaultBackground, format: "F2", formatProvider: null); -``` - -These overloads stay public mainly for compatibility and niche scenarios; for normal app code, prefer interpolated handler calls. +`WriteInterpolated` / `WriteLineInterpolated` should be your default output path for styled console text. ### Basic inputs ```csharp -if (!Console.TryReadLine(out int port, $"Port ({ConsoleColor.Green}5000{ConsoleColor.Default}): ")) { +if (!Console.TryReadLine(out int port, $"Port ({Green}5000{Default}): ")) { port = 5000; } @@ -177,7 +163,7 @@ if (!Console.TryReadLine(out DayOfWeek day, ignoreCase: true, $"Day? ")) { day = DayOfWeek.Monday; } -var apiKey = Console.ReadLine($"Enter API key ({ConsoleColor.DarkGray}optional{ConsoleColor.Default}): "); +var apiKey = Console.ReadLine($"Enter API key ({DarkGray}optional{Default}): "); ``` All input helpers work with `IParsable` and enums, respect the active culture, and honor `OutputPipe` when prompts are colored. @@ -185,9 +171,9 @@ All input helpers work with `IParsable` and enums, respect the active culture ### Advanced inputs ```csharp -Console.RequestAnyInput($"Press {ConsoleColor.Yellow}any key{ConsoleColor.Default} to continue…"); +Console.RequestAnyInput($"Press {Yellow}any key{Default} to continue…"); -if (!Console.Confirm($"Deploy to production? ({ConsoleColor.Green}y{ConsoleColor.Default}/{ConsoleColor.Red}n{ConsoleColor.Default}) ")) { +if (!Console.Confirm($"Deploy to production? ({Green}y{Default}/{Red}n{Default}) ")) { return; } @@ -207,7 +193,7 @@ Console.ResetColors(); Console.SkipLines(2); // keep multi-line UIs (progress bars, dashboards) and continue writing below them ``` -`ConsoleContext.Out`/`Error` expose the live writers (both are settable if you need to swap in test doubles). Use `Console.WriteWhiteSpaces(int length, OutputPipe pipe)` for convenient padding from call sites, or call `WriteWhiteSpaces(int)` on an existing writer. `Console.SkipLines(n)` advances the cursor without clearing so you can keep overwritten UI (progress bars, spinners, dashboards) visible after completion: +`ConsoleContext.Out`/`Error` expose the active writers, which is useful for tests or custom writer-based output. Use `Console.WriteWhiteSpaces(int length, OutputPipe pipe)` for convenient padding from call sites, or call `WriteWhiteSpaces(int)` on an existing writer. `Console.SkipLines(n)` advances the cursor without clearing so you can keep overwritten UI (progress bars, spinners, dashboards) visible after completion: ```csharp Console.WriteWhiteSpaces(8, OutputPipe.Error); // pad status blocks @@ -218,8 +204,8 @@ ConsoleContext.Error.WriteWhiteSpaces(4); // same via writer ```csharp Console.Overwrite(() => { - Console.WriteLineInterpolated(OutputPipe.Error, $"{ConsoleColor.Cyan}Working…{ConsoleColor.Default}"); - Console.WriteInterpolated(OutputPipe.Error, $"{ConsoleColor.DarkGray}Elapsed:{ConsoleColor.Default} {stopwatch.Elapsed:duration}"); // Elapsed: 0h 1m 12s + Console.WriteLineInterpolated(OutputPipe.Error, $"{Cyan}Working…{Default}"); + Console.WriteInterpolated(OutputPipe.Error, $"{DarkGray}Elapsed:{Default} {stopwatch.Elapsed:duration}"); // Elapsed: 0h 1m 12s }, lines: 2); // Prevent closure allocations with state + generic overload @@ -235,7 +221,7 @@ Always call `Console.ClearNextLines(totalLines, pipe)` once after the last `Over ### Live console regions -`LiveConsoleRegion` owns one retained live region on a single `OutputPipe` and coordinates it with durable line output on that same pipe. This is the right fit where status lines stream normally while a pinned transient line keeps updating at the bottom. +`LiveConsoleRegion` is useful when status lines should continue streaming normally while a pinned transient line keeps updating at the bottom. ```csharp using var live = new LiveConsoleRegion(OutputPipe.Error); @@ -251,7 +237,7 @@ live.Render($"Linking {elapsed:duration}"); live.Clear(); ``` -Use `WriteLine` for durable lines that should scroll above the retained region, `Render` for arbitrary transient snapshots, and `RenderProgress` when you want the built-in progress bar renderer inside the region. Keep all output that must coordinate with the live region flowing through that region instance. In interactive CLIs, `OutputPipe.Error` is usually the correct pipe so stdout remains machine-friendly. +Use `WriteLine` for lines that should scroll above the live region, `Render` for transient snapshots, and `RenderProgress` when you want the built-in progress bar inside the region. In interactive CLIs, `OutputPipe.Error` is usually the right pipe so stdout stays machine-friendly. ### Menus and tables @@ -295,11 +281,11 @@ progress.Update(42.5, "Syncing", sameLine: false); ProgressBar.Render(OutputPipe.Error, 75, ConsoleColor.Magenta, '*', maxLineWidth: 32); ``` -`ProgressBar.Update` always re-renders (even if the percentage didn't change) so you can refresh status text. You can also set `ProgressBar.MaxLineWidth` on the instance to limit the rendered `[=====] 42%` line width before each update, mirroring the `maxLineWidth` option on `ProgressBar.Render`. The helper `ProgressBar.Render` keeps the cursor on the same line, which is ideal inside `Console.Overwrite`, and accepts an optional `maxLineWidth` so the entire `[=====] 42%` line can be constrained for left-column layouts. For dynamic headers, use the overload that accepts a `PrettyConsoleInterpolatedStringHandlerFactory`, mirroring the spinner pattern. +`ProgressBar.Update` lets you keep refreshing status text and progress together. You can also set `ProgressBar.MaxLineWidth` on the instance to constrain the rendered line before each update, mirroring the `maxLineWidth` option on `ProgressBar.Render`. The helper `ProgressBar.Render` keeps the cursor on the same line, which is ideal inside `Console.Overwrite`. For dynamic headers, use the overload that accepts a `PrettyConsoleInterpolatedStringHandlerFactory`, mirroring the spinner pattern. #### Spinner (indeterminate progress) -`Spinner` renders animated frames on the error pipe. `PrettyConsoleInterpolatedStringHandlerFactory` overloads take a lambda that creates a `PrettyConsoleInterpolatedStringHandler` via the builder for per-frame headers: +`Spinner` renders animated frames on the error pipe. For dynamic per-frame headers, use the `PrettyConsoleInterpolatedStringHandlerFactory` overload: ```csharp var spinner = new Spinner(); @@ -307,7 +293,7 @@ await spinner.RunAsync(workTask, (builder, out handler) => handler = builder.Build(OutputPipe.Error, $"Syncing {DateTime.Now:T}")); ``` -The factory runs each frame so you can inject dynamic status text without allocations while avoiding extra struct copies. +The factory runs each frame, so the header can reflect changing status. #### Multiple progress bars with tasks + channels @@ -350,7 +336,7 @@ await consumer; Console.ClearNextLines(downloads.Length, OutputPipe.Error); // ensure no artifacts remain ``` -Each producer reports progress over the channel, the consumer loops with `ReadAllAsync`, and `Console.Overwrite` redraws the stacked bars on every update. After the consumer completes, clear the region once to remove the progress UI. +Each producer reports progress over the channel, the consumer redraws the stacked bars on every update, and the region is cleared once at the end. ### Pipes & writers @@ -364,6 +350,14 @@ TextReader @in = ConsoleContext.In; Use these when you need direct writer access (custom buffering, `WriteWhiteSpaces`, etc.) or swap in mocks for testing. In cases where you must call raw `System.Console` APIs (e.g., `Console.ReadKey(true)`), do so explicitly—PrettyConsole never hides the built-in console. +## Color model + +- Prefer `Color` and `Markup` inside `WriteInterpolated` / `WriteLineInterpolated`. +- Use `new AnsiToken("...")` for custom ANSI you want to interpolate like any other style token. +- `ConsoleColor` remains supported for compatibility and for APIs that explicitly take it (`ProgressBar`, `Spinner`, low-level `Write`/`WriteLine`, `Console.SetColors`, `TypeWrite`, and similar APIs). +- `AnsiColors` maps `ConsoleColor` values into the same token-based color model. +- If you use raw ANSI strings directly, PrettyConsole will not manage them for you. + ## Contributing Contributions are welcome! Fork the repo, create a branch, and open a pull request. Bug reports and feature requests are tracked through GitHub issues. diff --git a/Versions.md b/Versions.md index afd5fb0..436c0d6 100755 --- a/Versions.md +++ b/Versions.md @@ -1,10 +1,16 @@ # Versions -## v5.5.0 +## v6.0.0 - Added `LiveConsoleRegion` for retained live output on a single `OutputPipe`, enabling Cargo-style durable status lines above a pinned transient region. - `LiveConsoleRegion` exposes `WriteLine`, `Render`, `RenderProgress`, `Clear`, and `Dispose`. - Interpolated strings now bind directly to `LiveConsoleRegion`, so `$"..."` can be passed into `WriteLine` and `Render` naturally. +- Added `AnsiToken` as the guarded ANSI abstraction for interpolated output. +- Added `Color` as the preferred interpolation-facing color surface with cached tokens such as `Color.Green`, `Color.DefaultForeground`, `Color.DefaultBackground`, and `Color.Default`. +- **BREAKING**: `Markup` now exposes guarded `AnsiToken`s instead of raw `string` escape sequences. +- **BREAKING**: `AnsiColors.Foreground(ConsoleColor)` and `AnsiColors.Background(ConsoleColor)` now return cached `AnsiToken`s instead of raw ANSI strings and map to the `Color` cache. +- Added `ConsoleContext.IsAnsiSupported`, including Windows VT detection, so handler-emitted `Color`, `Markup`, `AnsiToken`, and `ConsoleColor` ANSI is suppressed when the terminal cannot safely render it. +- `ConsoleColor` interpolation remains supported for compatibility, but `Color` is now the preferred API for handler-based styled output. `ConsoleColor` remains the native input for APIs such as `ProgressBar`, `Spinner`, and low-level span-based writes. ## v5.4.2 @@ -51,7 +57,7 @@ ## v5.1.0 - `Console.WriteInterpolated` and `Console.WriteLineInterpolated` now return a `int` that contains the number of characters written using the handler. This could be used to help calculate paddings or other things when creating structured output. - - It will ignore escape sequences that were added using the handler like `ConsoleColor` or `Markup` but if you hardcode your own they might be taken into account. As such, if you do this, I recommend first checking the length without using `ConsoleColor` or `Markup`, then using this result for the calculation. + - It will ignore escape sequences that were added using the handler like `ConsoleColor`, `Color`, `Markup`, or any guarded `AnsiToken`, but if you hardcode your own they might be taken into account. As such, if you do this, I recommend first checking the length without using those helpers, then using this result for the calculation. - `PrettyConsoleExtensions` that contains the `Out`, `Err`, `In`, etc... was renamed to `ConsoleContext`. - The standard `Out`, `Err`, `In` streams now have a public setter, so end users could mock it in their own tests. - `Console.WriteWhiteSpaces(length, OutputPipe)` was added to reduce the complexity of using the `TextWriter` extension. From 999bc6bed241dc6a29a45ad0da432540ac587df8 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 27 Mar 2026 21:26:05 +0300 Subject: [PATCH 15/16] Polish docs --- .agents/skills/pretty-console-expert/SKILL.md | 6 +++--- AGENTS.md | 7 +++++-- PrettyConsole/PrettyConsole.csproj | 8 ++++--- README.md | 21 +++++++++++-------- Versions.md | 16 ++++++++------ 5 files changed, 35 insertions(+), 23 deletions(-) diff --git a/.agents/skills/pretty-console-expert/SKILL.md b/.agents/skills/pretty-console-expert/SKILL.md index cb7f0d2..a96f758 100644 --- a/.agents/skills/pretty-console-expert/SKILL.md +++ b/.agents/skills/pretty-console-expert/SKILL.md @@ -48,7 +48,7 @@ using static System.Console; // optional - Avoid span/formattable `Write`/`WriteLine` overloads in normal app code; reserve them for rare advanced/manual formatting scenarios. - If the intent is only to end the current line or emit a blank line, use `Console.NewLine(pipe)` instead of `WriteLineInterpolated($"")` or reset-only interpolations such as `$"{Color.Default}"`. - Keep ANSI/decorations inside interpolation holes (for example, `$"{Markup.Bold}..."`) instead of literal escape codes inside string literals. -- Prefer `Color`, `Markup`, and guarded `AnsiToken` in interpolated output. Keep `ConsoleColor` for APIs that explicitly require it (`ProgressBar`, `Spinner`, low-level span writes, `Console.SetColors`, `TypeWrite`, etc.). +- Prefer `Color`, `Markup`, and guarded `AnsiToken` in styled output. Use `Color.*` for token-based color APIs such as `ProgressBar`, `Spinner`, `TypeWrite`, and `LiveConsoleRegion.RenderProgress`. Keep `ConsoleColor` for APIs that explicitly require it, such as low-level span writes or `Console.SetColors`. - Route transient UI (spinner/progress/overwrite loops) to `OutputPipe.Error` to keep stdout pipe-friendly, and use `OutputPipe.Error` for genuine errors/diagnostics. Keep ordinary non-error interaction flow on `OutputPipe.Out`. - Spinner/progress/overwrite output is caller-owned after rendering completes. Explicitly remove it with `Console.ClearNextLines(totalLines, pipe)` or intentionally keep the region with `Console.SkipLines(totalLines)`. - `LiveConsoleRegion` is the right primitive when durable line output and transient status must interleave over time. It is line-oriented: use `WriteLine`, not inline writes, for cooperating durable output above the retained region. @@ -104,9 +104,9 @@ await spinner.RunAsync(workTask, (builder, out handler) => Console.ClearNextLines(1, OutputPipe.Error); // or Console.SkipLines(1) to keep the final row // Progress rendering -var bar = new ProgressBar { ProgressColor = ConsoleColor.Green }; +var bar = new ProgressBar { ProgressColor = Color.Green }; bar.Update(65, "Downloading", sameLine: true); -ProgressBar.Render(OutputPipe.Error, 65, ConsoleColor.Green); +ProgressBar.Render(OutputPipe.Error, 65, Color.Green); // Retained live region using var live = new LiveConsoleRegion(OutputPipe.Error); diff --git a/AGENTS.md b/AGENTS.md index c56526b..04cdab0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Summary - PrettyConsole.Tests/ — interactive/demo runner (manually selects visual feature demos) - PrettyConsole.UnitTests/ — TUnit-based automated tests run with `dotnet run` - Examples/ — standalone `.cs` sample apps plus `assets/` previews; documented in `Examples/README.md` and excluded from automated builds/tests -- v6.0.0 (current) follows released `v5.4.2` directly and introduces guarded ANSI tokens as the primary interpolation-facing styling model, adds `LiveConsoleRegion`, and adds `ConsoleContext.IsAnsiSupported` for platform-sensitive ANSI capability detection (including Windows VT checks). `Color` now provides cached `AnsiToken` values for foreground/background/default colors, `Markup` exposes guarded decoration tokens, `AnsiColors` maps `ConsoleColor` into that token cache, and `PrettyConsoleInterpolatedStringHandler` resets through `Color.Default`. `ConsoleColor` interpolation remains supported for compatibility while `ProgressBar`, `Spinner`, and other explicit color-taking APIs still use `ConsoleColor`. v5.4.0 renamed `IndeterminateProgressBar` to `Spinner` (and `AnimationSequence` to `Pattern`), triggers the line reset at the start of each spinner frame, gives all `RunAsync` overloads default cancellation tokens, and renames `ProgressBar.WriteProgressBar` to `Render` while adding handler-factory overloads and switching header parameters to `string`. It still passes handlers by `ref`, adds `AppendInline`, and introduces the ctor that takes only `OutputPipe` + optional `IFormatProvider`; `SkipLines` advances the cursor while keeping overwritten UIs; `Confirm(trueValues, ref handler, bool emptyIsTrue = true)` has the boolean last; spinner header factories use `PrettyConsoleInterpolatedStringHandlerFactory` with the singleton builder; `AnsiColors` is public. v5.2.0 rewrote the handler to buffer before writing and added `WhiteSpace`; v5.1.0 renamed `PrettyConsoleExtensions` to `ConsoleContext`, added `Console.WriteWhiteSpaces(length, pipe)`, and made `Out`/`Error`/`In` settable; v5.0.0 removed the legacy `ColoredOutput`/`Color` types in favor of `ConsoleColor` helpers and tuples. +- v6.0.0 (current) follows released `v5.4.2` directly. It adds `LiveConsoleRegion`, introduces `Color`/`Markup`/`AnsiToken` as the preferred styled-output model, extends that model to `ProgressBar`, `Spinner`, `TypeWrite`, and `LiveConsoleRegion.RenderProgress`, and adds `ConsoleContext.IsAnsiSupported` for ANSI capability checks. `Markup` and `AnsiColors` now expose `AnsiToken` values instead of raw strings. v5.4.0 renamed `IndeterminateProgressBar` to `Spinner` (and `AnimationSequence` to `Pattern`), triggers the line reset at the start of each spinner frame, gives all `RunAsync` overloads default cancellation tokens, and renames `ProgressBar.WriteProgressBar` to `Render` while adding handler-factory overloads and switching header parameters to `string`. It still passes handlers by `ref`, adds `AppendInline`, and introduces the ctor that takes only `OutputPipe` + optional `IFormatProvider`; `SkipLines` advances the cursor while keeping overwritten UIs; `Confirm(trueValues, ref handler, bool emptyIsTrue = true)` has the boolean last; spinner header factories use `PrettyConsoleInterpolatedStringHandlerFactory` with the singleton builder; `AnsiColors` is public. v5.2.0 rewrote the handler to buffer before writing and added `WhiteSpace`; v5.1.0 renamed `PrettyConsoleExtensions` to `ConsoleContext`, added `Console.WriteWhiteSpaces(length, pipe)`, and made `Out`/`Error`/`In` settable; v5.0.0 removed the legacy `ColoredOutput`/`Color` types in favor of `ConsoleColor` helpers and tuples. Commands you’ll use often @@ -38,6 +38,9 @@ Repo-specific agent rules and conventions - When unifying colored output paths, prefer composing through `WriteInterpolated`/`WriteLineInterpolated` and the existing `Color`/`Markup`/`AnsiToken` interpolation model rather than adding new handler hooks or duplicating ANSI logic elsewhere. - If the requested direction may already exist in the worktree, inspect staged changes before designing the implementation so you follow the repository's intended approach instead of inventing a parallel one. - Adhere to .editorconfig in the repo for style and analyzers. +- When editing `README.md`, keep it user-facing: preserve strong product positioning and user-visible achievements (performance, allocation profile, ergonomics) while removing only internal mechanics that do not help someone use the library. +- Do not downplay PrettyConsole's differentiators when cleaning docs. Performance characteristics and zero-allocation goals are part of the product story, not implementation noise. +- In changelogs and NuGet release notes, include only user-facing changes. Separate `Added` changes from `Breaking` changes, and do not label something as breaking when the old API still works but is merely no longer the preferred path. - If code needs to be “removed” as part of a change, do not delete files; comment out their contents so they won’t compile. - Avoid reflection/dynamic assembly loading in published library code unless explicitly requested. @@ -54,7 +57,7 @@ High-level architecture and key concepts - Coloring model - `Color` is the preferred handler-facing color API. It exposes cached guarded `AnsiToken`s such as `Color.Green`, `Color.GreenBackground`, `Color.DefaultForeground`, `Color.DefaultBackground`, and `Color.Default`. - `AnsiToken` is the guarded ANSI abstraction used by the interpolated string handler. Use `new AnsiToken("...")` for custom guarded ANSI holes. - - `ConsoleColor` interpolation remains supported for compatibility, but explicit `ConsoleColor` APIs are now primarily for low-level writes and components like `ProgressBar`, `Spinner`, `Console.SetColors`, and `TypeWrite`. + - `ConsoleColor` interpolation remains supported for compatibility, and low-level writes plus APIs like `Console.SetColors` still use explicit `ConsoleColor`. - `AnsiColors` maps `ConsoleColor` values into the cached `Color` token surface (`AnsiColors.Foreground(ConsoleColor.Green)` and `Color.Green` are the same token). - Markup decorations - The `Markup` static class exposes guarded `AnsiToken`s for underline, bold, italic, and strikethrough. These are suppressed by the handler when output is redirected or ANSI is unsupported. `Markup.Reset` resets both decorations and colors. diff --git a/PrettyConsole/PrettyConsole.csproj b/PrettyConsole/PrettyConsole.csproj index 42347fe..578602b 100755 --- a/PrettyConsole/PrettyConsole.csproj +++ b/PrettyConsole/PrettyConsole.csproj @@ -44,9 +44,11 @@ - - Added LiveConsoleRegion for retained live output on a single OutputPipe, enabling Cargo-style durable status lines above a pinned transient region. - - LiveConsoleRegion exposes WriteLine, Render, RenderProgress, Clear, and Dispose. - - Interpolated strings now bind directly to LiveConsoleRegion so $"..." can be passed into region methods naturally. + - Added LiveConsoleRegion for retained live output on one pipe. + - Added Color and AnsiToken as the preferred styled-output color model. + - Added token-based color support across ProgressBar, Spinner, TypeWrite, and live-region progress rendering. + - Added Windows ANSI capability detection for styled output. + - BREAKING: Markup and AnsiColors now expose guarded AnsiToken values instead of raw strings. diff --git a/README.md b/README.md index 4e8be4e..e34121d 100755 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ This setup lets you call `Console.WriteInterpolated`, `Console.Overwrite`, `Cons `Console.WriteInterpolated` and `Console.WriteLineInterpolated` are the main output APIs for styled text. Colors reset automatically at the end of each call, and both methods return the number of visible characters written so you can reuse the result in padding or layout calculations. -For interpolation, prefer `Color` and `Markup`. `ConsoleColor` interpolation still works, but `Color` is the primary public surface for guarded styled output. +For interpolation, prefer `Color` and `Markup`. `ConsoleColor` interpolation still works, but `Color` is the primary public surface for styled output. ```csharp Console.WriteInterpolated($"Hello {Green}world{Default}!"); @@ -93,7 +93,9 @@ if (!Console.TryReadLine(out int choice, $"Pick option {Cyan}1-5{Default}: ")) { Console.WriteInterpolated($"Header{new WhiteSpace(6)}Value"); ``` -`Color` exposes tokens for both foreground and background colors (`Green`, `GreenBackground`, `DefaultForeground`, `DefaultBackground`, `Default`). `AnsiColors` is available when you want to convert an existing `ConsoleColor` value into the same style of token, while APIs like `ProgressBar` and span-based `Write`/`WriteLine` still take `ConsoleColor` directly. +`Color` exposes tokens for both foreground and background colors (`Green`, `GreenBackground`, `DefaultForeground`, `DefaultBackground`, `Default`). `AnsiColors` is available when you want to convert an existing `ConsoleColor` value into the same style of token. + +For color-specific APIs that take `AnsiToken`, prefer using `Color.*`. `ConsoleColor` still works in many foreground-only call sites through implicit conversion, but `Color` is the clearer and more expressive API. #### Inline decorations via `Markup` @@ -213,8 +215,8 @@ Console.Overwrite((left, right), tuple => { Console.WriteInterpolated($"{tuple.left} ←→ {tuple.right}"); }, lines: 1); -await Console.TypeWrite("Booting systems…", (ConsoleColor.Green, ConsoleColor.Black)); -await Console.TypeWriteLine("Ready.", ConsoleColor.Default); +await Console.TypeWrite("Booting systems…", (Color.Green, Color.BlackBackground)); +await Console.TypeWriteLine("Ready.", (Color.DefaultForeground, Color.DefaultBackground)); ``` Always call `Console.ClearNextLines(totalLines, pipe)` once after the last `Overwrite` to erase the region when you are done. @@ -265,8 +267,8 @@ Menus validate user input (throwing `ArgumentException` on invalid selections) a ```csharp var progress = new ProgressBar { ProgressChar = '■', - ForegroundColor = ConsoleColor.DarkGray, - ProgressColor = ConsoleColor.Green, + ForegroundColor = Color.DarkGray, + ProgressColor = Color.Green, }; for (int i = 0; i <= 100; i += 5) { @@ -278,7 +280,7 @@ for (int i = 0; i <= 100; i += 5) { progress.Update(42.5, "Syncing", sameLine: false); // One-off render without state -ProgressBar.Render(OutputPipe.Error, 75, ConsoleColor.Magenta, '*', maxLineWidth: 32); +ProgressBar.Render(OutputPipe.Error, 75, Color.Magenta, '*', maxLineWidth: 32); ``` `ProgressBar.Update` lets you keep refreshing status text and progress together. You can also set `ProgressBar.MaxLineWidth` on the instance to constrain the rendered line before each update, mirroring the `maxLineWidth` option on `ProgressBar.Render`. The helper `ProgressBar.Render` keeps the cursor on the same line, which is ideal inside `Console.Overwrite`. For dynamic headers, use the overload that accepts a `PrettyConsoleInterpolatedStringHandlerFactory`, mirroring the spinner pattern. @@ -323,7 +325,7 @@ var consumer = Task.Run(async () => { Console.Overwrite(progress, state => { for (int i = 0; i < state.Length; i++) { Console.WriteInterpolated(OutputPipe.Error, $"Task {i + 1} ({downloads[i]}): "); - ProgressBar.Render(OutputPipe.Error, state[i], ConsoleColor.Cyan); + ProgressBar.Render(OutputPipe.Error, state[i], Color.Cyan); } }, lines: downloads.Length, pipe: OutputPipe.Error); } @@ -354,7 +356,8 @@ Use these when you need direct writer access (custom buffering, `WriteWhiteSpace - Prefer `Color` and `Markup` inside `WriteInterpolated` / `WriteLineInterpolated`. - Use `new AnsiToken("...")` for custom ANSI you want to interpolate like any other style token. -- `ConsoleColor` remains supported for compatibility and for APIs that explicitly take it (`ProgressBar`, `Spinner`, low-level `Write`/`WriteLine`, `Console.SetColors`, `TypeWrite`, and similar APIs). +- Use `Color.*` for color-specific APIs that take `AnsiToken`, including `ProgressBar`, `Spinner`, `TypeWrite`, and `LiveConsoleRegion.RenderProgress`. +- `ConsoleColor` remains part of the API for compatibility and for members that still use explicit console colors, such as low-level `Write`/`WriteLine` overloads and `Console.SetColors`. - `AnsiColors` maps `ConsoleColor` values into the same token-based color model. - If you use raw ANSI strings directly, PrettyConsole will not manage them for you. diff --git a/Versions.md b/Versions.md index 436c0d6..da8b923 100755 --- a/Versions.md +++ b/Versions.md @@ -2,15 +2,19 @@ ## v6.0.0 +### Added + - Added `LiveConsoleRegion` for retained live output on a single `OutputPipe`, enabling Cargo-style durable status lines above a pinned transient region. -- `LiveConsoleRegion` exposes `WriteLine`, `Render`, `RenderProgress`, `Clear`, and `Dispose`. -- Interpolated strings now bind directly to `LiveConsoleRegion`, so `$"..."` can be passed into `WriteLine` and `Render` naturally. - Added `AnsiToken` as the guarded ANSI abstraction for interpolated output. - Added `Color` as the preferred interpolation-facing color surface with cached tokens such as `Color.Green`, `Color.DefaultForeground`, `Color.DefaultBackground`, and `Color.Default`. -- **BREAKING**: `Markup` now exposes guarded `AnsiToken`s instead of raw `string` escape sequences. -- **BREAKING**: `AnsiColors.Foreground(ConsoleColor)` and `AnsiColors.Background(ConsoleColor)` now return cached `AnsiToken`s instead of raw ANSI strings and map to the `Color` cache. -- Added `ConsoleContext.IsAnsiSupported`, including Windows VT detection, so handler-emitted `Color`, `Markup`, `AnsiToken`, and `ConsoleColor` ANSI is suppressed when the terminal cannot safely render it. -- `ConsoleColor` interpolation remains supported for compatibility, but `Color` is now the preferred API for handler-based styled output. `ConsoleColor` remains the native input for APIs such as `ProgressBar`, `Spinner`, and low-level span-based writes. +- Added token-based color support across `ProgressBar`, `Spinner`, `TypeWrite`, and `LiveConsoleRegion.RenderProgress`. +- Added Windows ANSI capability detection so PrettyConsole can avoid emitting styled ANSI output when the terminal cannot safely render it. +- `ConsoleColor` remains supported for compatibility, but `Color` is now the preferred API for styled output. + +### Breaking + +- `Markup` now exposes guarded `AnsiToken`s instead of raw `string` escape sequences. +- `AnsiColors.Foreground(ConsoleColor)` and `AnsiColors.Background(ConsoleColor)` now return cached `AnsiToken`s instead of raw ANSI strings and map to the `Color` cache. ## v5.4.2 From 3618c4ce77ee5a57c35f2a1e4c548e1636f3e2ae Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 27 Mar 2026 21:34:04 +0300 Subject: [PATCH 16/16] Adjust tests --- .../Features/MultiProgressBarLeftAlignedTest.cs | 4 ++-- PrettyConsole.Tests/Features/MultiProgressBarTest.cs | 4 ++-- PrettyConsole.Tests/Features/ProgressBarDefaultTest.cs | 2 +- PrettyConsole.Tests/Features/ProgressBarMultiLineTest.cs | 2 +- PrettyConsole.Tests/Features/SpinnerTest.cs | 4 ++-- PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/PrettyConsole.Tests/Features/MultiProgressBarLeftAlignedTest.cs b/PrettyConsole.Tests/Features/MultiProgressBarLeftAlignedTest.cs index 46f3499..30d54ea 100755 --- a/PrettyConsole.Tests/Features/MultiProgressBarLeftAlignedTest.cs +++ b/PrettyConsole.Tests/Features/MultiProgressBarLeftAlignedTest.cs @@ -10,9 +10,9 @@ public async ValueTask Implementation() { double percentage = 100 * (double)i / count; Console.Overwrite((int)percentage, p => { - ProgressBar.Render(OutputPipe.Error, p, ConsoleColor.Magenta, maxLineWidth: 50); + ProgressBar.Render(OutputPipe.Error, p, Color.Magenta, maxLineWidth: 50); Console.WriteLineInterpolated(OutputPipe.Error, $" - Task {1}"); - ProgressBar.Render(OutputPipe.Error, p, ConsoleColor.Magenta, maxLineWidth: 50); + ProgressBar.Render(OutputPipe.Error, p, Color.Magenta, maxLineWidth: 50); Console.WriteInterpolated(OutputPipe.Error, $" - Task {2}"); }, 2); diff --git a/PrettyConsole.Tests/Features/MultiProgressBarTest.cs b/PrettyConsole.Tests/Features/MultiProgressBarTest.cs index 2865f5d..988536e 100755 --- a/PrettyConsole.Tests/Features/MultiProgressBarTest.cs +++ b/PrettyConsole.Tests/Features/MultiProgressBarTest.cs @@ -11,9 +11,9 @@ public async ValueTask Implementation() { Console.Overwrite((int)percentage, p => { Console.WriteInterpolated(OutputPipe.Error, $"Task {1}: "); - ProgressBar.Render(OutputPipe.Error, p, ConsoleColor.Magenta); + ProgressBar.Render(OutputPipe.Error, p, Color.Magenta); Console.WriteInterpolated(OutputPipe.Error, $"Task {2}: "); - ProgressBar.Render(OutputPipe.Error, p, ConsoleColor.Magenta); + ProgressBar.Render(OutputPipe.Error, p, Color.Magenta); }, 2); await Task.Delay(15); diff --git a/PrettyConsole.Tests/Features/ProgressBarDefaultTest.cs b/PrettyConsole.Tests/Features/ProgressBarDefaultTest.cs index 066a771..d972b63 100755 --- a/PrettyConsole.Tests/Features/ProgressBarDefaultTest.cs +++ b/PrettyConsole.Tests/Features/ProgressBarDefaultTest.cs @@ -8,7 +8,7 @@ public sealed class ProgressBarDefaultTest : IPrettyConsoleTest { public async ValueTask Implementation() { var prg = new ProgressBar { - ProgressColor = ConsoleColor.Magenta, + ProgressColor = Color.Magenta, }; const int count = 333; for (int i = 1; i <= count; i++) { diff --git a/PrettyConsole.Tests/Features/ProgressBarMultiLineTest.cs b/PrettyConsole.Tests/Features/ProgressBarMultiLineTest.cs index 6b341da..2f26f92 100755 --- a/PrettyConsole.Tests/Features/ProgressBarMultiLineTest.cs +++ b/PrettyConsole.Tests/Features/ProgressBarMultiLineTest.cs @@ -8,7 +8,7 @@ public sealed class ProgressBarMultiLineTest : IPrettyConsoleTest { public async ValueTask Implementation() { var prg = new ProgressBar { - ProgressColor = ConsoleColor.Magenta, + ProgressColor = Color.Magenta, }; const int count = 333; for (int i = 1; i <= count; i++) { diff --git a/PrettyConsole.Tests/Features/SpinnerTest.cs b/PrettyConsole.Tests/Features/SpinnerTest.cs index 7838723..ec56c52 100755 --- a/PrettyConsole.Tests/Features/SpinnerTest.cs +++ b/PrettyConsole.Tests/Features/SpinnerTest.cs @@ -6,9 +6,9 @@ public sealed class SpinnerTest : IPrettyConsoleTest { public async ValueTask Implementation() { var spinner = new Spinner { Pattern = Spinner.Patterns.Braille, - ForegroundColor = ConsoleColor.Magenta, + ForegroundColor = Color.Magenta, DisplayElapsedTime = true }; - await spinner.RunAsync(Task.Delay(5_000), (builder, out handler) => handler = builder.Build(OutputPipe.Error, $"...{ConsoleColor.Green}Running{ConsoleColor.DefaultForeground}...")); + await spinner.RunAsync(Task.Delay(5_000), (builder, out handler) => handler = builder.Build(OutputPipe.Error, $"...{Color.Green}Running{Color.DefaultForeground}...")); } } \ No newline at end of file diff --git a/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs b/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs index 4d6053d..aff627c 100644 --- a/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs +++ b/PrettyConsole/PrettyConsoleInterpolatedStringHandler.cs @@ -531,7 +531,7 @@ public void FlushWithoutWrite() { public void Flush(bool resetColors = true) { ThrowIfFlushed(); if (resetColors) ResetColors(); - Span written = new(_buffer, 0, _index); + var written = Written; _writer.Write(written); written.Clear(); BufferPool.Return(_buffer, false);