Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ Summary
- PrettyConsole/ — main library
- PrettyConsole.Tests/ — interactive/demo runner (manually selects visual feature demos)
- PrettyConsole.Tests.Unit/ — xUnit v3 unit tests using Microsoft Testing Platform
- v5.3.0 (current) makes more `PrettyConsoleInterpolatedStringHandler` members public, adds `AppendInline` for nesting handlers, introduces a ctor that takes only `OutputPipe` + optional `IFormatProvider`, and passes handlers by `ref` to callers. It adds `SkipLines` to advance the cursor while keeping overwritten UIs, reorders `Confirm(trueValues, ref handler, bool emptyIsTrue = true)` arguments (the boolean is now last), switches `IndeterminateProgressBar` header factories to `PrettyConsoleInterpolatedStringHandlerFactory` with the singleton `PrettyConsoleInterpolatedStringHandlerBuilder`, and makes `AnsiColors` 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.
- Examples/ — standalone `.cs` sample apps plus `assets/` previews; documented in `Examples/README.md` and excluded from automated builds/tests
- v5.3.1 (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.

Commands you’ll use often

Expand Down Expand Up @@ -67,8 +68,8 @@ High-level architecture and key concepts
- 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
- `IndeterminateProgressBar` binds to running `Task` instances, optionally starts tasks, supports cancellable `RunAsync` overloads, exposes `AnimationSequence`, `Patterns`, `ForegroundColor`, `DisplayElapsedTime`, and `UpdateRate`. Frames render on the error pipe and auto-clear. v5.3.0 switches header factories to `PrettyConsoleInterpolatedStringHandlerFactory`; use `(builder, out handler) => handler = builder.Build(OutputPipe.Error, $"status")` with the singleton `PrettyConsoleInterpolatedStringHandlerBuilder`.
- `ProgressBar` maintains a single-line bar on the error pipe. `Update` accepts `int`/`double` percentages plus optional status spans, and exposes `ProgressChar`, `ForegroundColor`, and `ProgressColor` for customization. The static `ProgressBar.WriteProgressBar` helper renders one-off segments without moving the cursor (so you can stack multiple bars within an `Overwrite` block).
- `Spinner` (formerly `IndeterminateProgressBar`) binds to running `Task` instances, optionally starts tasks, supports cancellable `RunAsync` overloads with default tokens, exposes `Pattern` (formerly `AnimationSequence`), `Patterns`, `ForegroundColor`, `DisplayElapsedTime`, and `UpdateRate`. Frames render on the error pipe and auto-clear. Header factories use `PrettyConsoleInterpolatedStringHandlerFactory`; call `(builder, out handler) => handler = builder.Build(OutputPipe.Error, $"status")` with the singleton `PrettyConsoleInterpolatedStringHandlerBuilder`.
- `ProgressBar` maintains a single-line bar on the error pipe. `Update` accepts `int`/`double` percentages plus optional status spans, and exposes `ProgressChar`, `ForegroundColor`, and `ProgressColor` for customization. The static `ProgressBar.Render` helper (renamed from `WriteProgressBar`) renders one-off segments without moving the cursor, so you can stack multiple bars within an `Overwrite` block. It now also has overloads that accept `PrettyConsoleInterpolatedStringHandlerFactory` for low-allocation headers.
- Packaging and targets
- `PrettyConsole.csproj` targets net10.0, enables trimming/AOT (`IsTrimmable`, `IsAotCompatible`), embeds SourceLink, and grants `InternalsVisibleTo` the unit-test project.

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

Notes and gotchas

- The library aims to minimize allocations; prefer span-based overloads (`ReadOnlySpan<char>`, `ISpanFormattable`) plus the inline `ConsoleColor` tuples instead of recreating strings or structs.
- When authoring new features, pick the appropriate OutputPipe to keep CLI piping behavior intact.
- On macOS terminals, ANSI is supported; Windows legacy terminals are handled via ANSI-compatible rendering in the library.
- `ProgressBar.Update` re-renders on every call (even when the percentage is unchanged) and accepts `sameLine` to place the status above the bar; the static `ProgressBar.WriteProgressBar` renders one-off bars without writing a trailing newline, so rely on `Console.Overwrite`/`lines` to stack multiple bars cleanly.
- `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.
- After the final `Overwrite`/`Overwrite<TState>` call in a rendering loop, call `Console.ClearNextLines(totalLines, pipe)` once more to clear the region and prevent ghost text.
29 changes: 29 additions & 0 deletions Examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Examples Gallery

These are simple examples of UIs that can be achieved by properly using the APIs that `PrettyConsole` provides. The examples are written using .NET 10 file-based apps, and can be run with `dotnet run example.cs` (change to the specific "app" name that you want).

- Please keep in mind that colors in the recordings might not appear exactly like their name in code (i.e `ConsoleColor.Green` may be a different color in the recordings), this is not an issue with the package, it is the because themes are used in the environment of the recordings, and they override how colors are rendered.

## [Interactive menu](interactive-menu.cs)

Three-step wizard that reuses the same screen area via `Console.Overwrite` instead of scrolling, keeping prompts tidy.

![Interactive menu demo](assets/interactive-menu.gif)

## [Progress with status](progress-with-status.cs)

Simple progress bar paired with a live status line underneath—good for downloads or migrations.

![Progress with status demo](assets/progress-with-status.gif)

## [Compilation spinner](compilation-spinner.cs)

Single-line braille spinner with step labels and elapsed time for a “building…” style readout.

![Compilation spinner demo](assets/compilation-spinner.gif)

## [Brew‑style downloads](brew-style-downloads.cs)

Shows multiple concurrent package downloads with a braille spinner, aligned byte counters, and overwrite-based repainting on the error pipe.

![Brew-style downloads demo](assets/brew-style-downloads.gif)
Binary file added Examples/assets/brew-style-downloads.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Examples/assets/compilation-spinner.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Examples/assets/interactive-menu.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Examples/assets/progress-with-status.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
117 changes: 117 additions & 0 deletions Examples/brew-style-downloads.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#:package PrettyConsole@5.4.0

using PrettyConsole;

bool keepProgressOutput = true;

var downloads = BrewStyleDownloads.CreateDownloadTasks();
var count = downloads.Count;

var spinner = Spinner.Patterns.Braille;
var spinnerLength = spinner.Count;
int spinnerIndex = 0;

var bufferWidth = Console.BufferWidth;

var task = Task.WhenAll(downloads.Select(BrewStyleDownloads.AdvanceDownload));

Console.CursorVisible = false; // Hide cursor to see progress better
while (!task.IsCompleted) {
spinnerIndex = (spinnerIndex + 1) % spinnerLength;

Console.Overwrite(() => {
foreach (var download in downloads) {
int written = 0;
if (download.IsComplete) {
written += Console.WriteInterpolated(OutputPipe.Error, $"{ConsoleColor.Green}✔︎{ConsoleColor.DefaultForeground} {download.Name}") - 1;
// I remove 1 from written here because "✔︎" is 2 characters long but renders a single block in the terminal
} else {
written += Console.WriteInterpolated(OutputPipe.Error, $"{ConsoleColor.Green}{spinner[spinnerIndex]}{ConsoleColor.DefaultForeground} {download.Name}");
}

var current = (double)download.BytesDownloaded;
var total = (double)download.FileSize;

var padding = bufferWidth - 34 - written;

// progress section takes 34 characters - found by measuring in test run
// var progressLength = Console.WriteLineInterpolated(... without WhiteSpace)
Console.WriteLineInterpolated(OutputPipe.Error, $"{new WhiteSpace(padding)}[Downloaded {current,10:bytes}/{total,10:bytes}]");
}
}, count);

await Task.Delay(25);
}
Console.CursorVisible = true; // restore cursor visibility

if (keepProgressOutput) {
Console.SkipLines(count);
Console.WriteLine(); // Add a newline to separate progress from future outputs
} else {
Console.ClearNextLines(count, OutputPipe.Error);
}

Console.WriteLineInterpolated($"{ConsoleColor.Green}Done!{ConsoleColor.DefaultForeground}");

/// <summary>
/// Represents a single download in a "brew" style feed with progress tracking.
/// </summary>
sealed class DownloadStyleTask {
public DownloadStyleTask(string name, long fileSize) {
Name = name;
FileSize = fileSize;
}

public string Name { get; }

public long BytesDownloaded { get; private set; }

public bool IsComplete { get; private set; }

public long FileSize { get; }

/// <summary>
/// Advance the download and notify any listeners.
/// </summary>
public void Advance(long bytes) {
if (bytes <= 0 || IsComplete) {
return;
}

var updated = BytesDownloaded + bytes;
BytesDownloaded = updated >= FileSize ? FileSize : updated;
if (BytesDownloaded == FileSize) IsComplete = true;
}
}

static class BrewStyleDownloads
{
/// <summary>
/// Creates sample download tasks you can feed into a renderer or progress loop.
/// Each task exposes an event so a UI can react when progress changes.
/// </summary>
public static List<DownloadStyleTask> CreateDownloadTasks() {
var tasks = new List<DownloadStyleTask>
{
new("git", 48_000_000),
new("curl", 27_500_000),
new("openssl", 96_000_000),
new("python@3.13", 121_000_000)
};

return tasks;
}

public static async Task AdvanceDownload(DownloadStyleTask task) {
const long minChunkSize = 1_000_000;

while (task.BytesDownloaded < task.FileSize) {
var chunk = Random.Shared.NextInt64(minChunkSize, 2 * minChunkSize);
var remaining = task.FileSize - task.BytesDownloaded;
var current = Math.Min(chunk, remaining);
task.Advance(current);
var delay = Random.Shared.Next(150, 250);
await Task.Delay(delay);
}
}
}
36 changes: 36 additions & 0 deletions Examples/compilation-spinner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#:package PrettyConsole@5.4.0

using System.Diagnostics;
using PrettyConsole;

string[] steps = [
"Restore",
"Compile",
"Link native shims",
"Run analyzers",
"Pack artifacts",
];

var step = 0;
var start = Stopwatch.GetTimestamp();

var build = Task.Run(async () => {
for (; step < steps.Length; Interlocked.Increment(ref step)) {
await Task.Delay(800);
}
});

var spinner = new Spinner {
Pattern = Spinner.Patterns.Braille,
ForegroundColor = ConsoleColor.Green,
DisplayElapsedTime = true,
UpdateRate = 100,
};

await spinner.RunAsync(build, (builder, out handler) => {
var current = Volatile.Read(ref step);
handler = builder.Build(OutputPipe.Error, $"Current step: {ConsoleColor.Green}{steps[current]}");
}, CancellationToken.None);

var elapsed = Stopwatch.GetElapsedTime(start);
Console.WriteLineInterpolated($"Build complete in {ConsoleColor.Green}{elapsed:duration}");
53 changes: 53 additions & 0 deletions Examples/interactive-menu.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#:package PrettyConsole@5.4.0

using PrettyConsole;

var environment = PromptSelection(
title: "Select environment",
options: ["Development", "Staging", "Production"]);

var features = PromptMultiSelection(
title: "Choose features (space to pick multiple)",
options: ["Core", "Metrics", "Tracing"]);

var region = PromptSelection(
title: "Pick region",
options: ["us-east", "us-west", "eu-central"]);

Console.WriteLineInterpolated($"{ConsoleColor.Green}Ready to deploy!");
Console.WriteLineInterpolated($"Environment: {Markup.Underline}{environment}{Markup.ResetUnderline}");
Console.WriteLineInterpolated($"Features: {Markup.Underline}{string.Join(", ", features)}{Markup.ResetUnderline}");
Console.WriteLineInterpolated($"Region: {Markup.Underline}{region}{Markup.ResetUnderline}");

// The following helper methods wrap the selection in Overwrite to resemble page routes
// If you want simple scrolling you can use Console.Selection or Console.MultiSelection directly.

static string PromptSelection(string title, string[] options) {
string selection = string.Empty;

while (selection.Length == 0) {
Console.Overwrite(() => {
selection = Console.Selection(options, $"{ConsoleColor.Cyan}{title}{ConsoleColor.DefaultForeground}:");
if (selection.Length == 0) {
Console.WriteLineInterpolated(OutputPipe.Error, $"{ConsoleColor.Red}Invalid choice. Try again.");
}
}, lines: options.Length + 3, pipe: OutputPipe.Out);
}

return selection;
}

static string[] PromptMultiSelection(string title, string[] options) {
string[] selection = Array.Empty<string>();

while (selection.Length == 0) {
Console.Overwrite(() => {
selection = Console.MultiSelection(options, $"{ConsoleColor.Cyan}{title}{ConsoleColor.DefaultForeground}:");
if (selection.Length == 0) {
Console.WriteLineInterpolated(OutputPipe.Error, $"{ConsoleColor.Red}Please pick at least one option.");
}
}, lines: options.Length + 3, pipe: OutputPipe.Out);
}

return selection;
}
18 changes: 18 additions & 0 deletions Examples/progress-with-status.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#:package PrettyConsole@5.4.0

using PrettyConsole;

Console.CursorVisible = false;
for (int i = 0; i <= 100; i += 4) {
Console.Overwrite(i, static ii => {
ProgressBar.Render(OutputPipe.Error, ii, ConsoleColor.Cyan, maxLineWidth: 40);
Console.NewLine(OutputPipe.Error);
Console.WriteInterpolated(OutputPipe.Error, $"Downloading assets... {ConsoleColor.Cyan}{ii}");
}, lines: 2, pipe: OutputPipe.Error);

await Task.Delay(70);
}
Console.CursorVisible = true;

Console.ClearNextLines(2, OutputPipe.Error);
Console.WriteLineInterpolated($"{ConsoleColor.Green}Download complete!");
15 changes: 0 additions & 15 deletions PrettyConsole.Tests/Features/IndeterminateProgressBarTest.cs

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ public async ValueTask Implementation() {
double percentage = 100 * (double)i / count;

Console.Overwrite((int)percentage, p => {
ProgressBar.WriteProgressBar(OutputPipe.Error, p, ConsoleColor.Magenta, maxLineWidth: 50);
ProgressBar.Render(OutputPipe.Error, p, ConsoleColor.Magenta, maxLineWidth: 50);
Console.WriteLineInterpolated(OutputPipe.Error, $" - Task {1}");
ProgressBar.WriteProgressBar(OutputPipe.Error, p, ConsoleColor.Magenta, maxLineWidth: 50);
ProgressBar.Render(OutputPipe.Error, p, ConsoleColor.Magenta, maxLineWidth: 50);
Console.WriteInterpolated(OutputPipe.Error, $" - Task {2}");
}, 2);

Expand Down
4 changes: 2 additions & 2 deletions PrettyConsole.Tests/Features/MultiProgressBarTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ public async ValueTask Implementation() {

Console.Overwrite((int)percentage, p => {
Console.WriteInterpolated(OutputPipe.Error, $"Task {1}: ");
ProgressBar.WriteProgressBar(OutputPipe.Error, p, ConsoleColor.Magenta);
ProgressBar.Render(OutputPipe.Error, p, ConsoleColor.Magenta);
Console.WriteInterpolated(OutputPipe.Error, $"Task {2}: ");
ProgressBar.WriteProgressBar(OutputPipe.Error, p, ConsoleColor.Magenta);
ProgressBar.Render(OutputPipe.Error, p, ConsoleColor.Magenta);
}, 2);

await Task.Delay(15);
Expand Down
14 changes: 14 additions & 0 deletions PrettyConsole.Tests/Features/SpinnerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace PrettyConsole.Tests.Features;

public sealed class SpinnerTest : IPrettyConsoleTest {
public string FeatureName => "Spinner";

public async ValueTask Implementation() {
var spinner = new Spinner {
Pattern = Spinner.Patterns.Braille,
ForegroundColor = ConsoleColor.Magenta,
DisplayElapsedTime = true
};
await spinner.RunAsync(Task.Delay(5_000), (builder, out handler) => handler = builder.Build(OutputPipe.Error, $"...{ConsoleColor.Green}Running{ConsoleColor.DefaultForeground}..."));
}
}
2 changes: 1 addition & 1 deletion PrettyConsole.Tests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
new MultiSelectionTest(),
new TableTest(),
new TreeMenuTest(),
new IndeterminateProgressBarTest(),
new SpinnerTest(),
new ProgressBarDefaultTest(),
new ProgressBarMultiLineTest(),
new MultiProgressBarTest(),
Expand Down
2 changes: 0 additions & 2 deletions PrettyConsole.UnitTests/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
using TUnit.Core;

[assembly: NotInParallel]
Loading