splitter/splitter-cli/tui/SpectreConsoleLogger.cs

395 lines
12 KiB
C#

using System.Text;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace splitter.tui;
/// <summary>
/// Spectre.Console-based live TUI logger.
/// - Title centered at top of outer box
/// - Progress section (N rows) with name, gradient bar, %, ETA, FPS
/// - Log section taking remaining space, auto-scrolling to latest messages
/// - Bottom row with a [ Cancel ] button (dummy handler: key 'c' / 'C')
/// - Resizes with console window
/// </summary>
public sealed class SpectreConsoleLogger : ILogger, IDisposable
{
private readonly object _sync = new();
private readonly List<LogEntry> _logs = new();
private readonly Dictionary<int, ProgressEntry> _progress = new();
private readonly CancellationTokenSource _cts = new();
private Task? _uiTask;
private Task? _inputTask;
private int _numberOfProcesses = 1;
private const int _maxLogEntries = 500;
// Public configuration
public string Title { get; set; } = string.Empty;
/// <summary>
/// Number of logical progress rows. UI reacts dynamically.
/// </summary>
public int NumberOfProcesses
{
get => _numberOfProcesses;
set
{
lock (_sync)
{
_numberOfProcesses = Math.Max(1, value);
for (var i = 0; i < _numberOfProcesses; i++)
{
if (!_progress.ContainsKey(i))
_progress[i] = ProgressEntry.Empty;
}
}
}
}
// ---- ILogger ----
public void ClearProgress(string name, int progressLine)
{
lock (_sync)
{
_progress[progressLine] = ProgressEntry.Empty;
}
}
public void DrawProgress(string name, int progressLine, double progress, TimeSpan eta, double speed)
{
lock (_sync)
{
if (progressLine < 0)
return;
if (progressLine >= NumberOfProcesses)
NumberOfProcesses = progressLine + 1;
_progress[progressLine] = new ProgressEntry(
name ?? string.Empty,
Math.Clamp(progress, 0.0, 1.0),
eta,
speed
);
}
}
public void Log(string prefix, ConsoleColor color, string msg)
{
lock (_sync)
{
if (_logs.Count >= _maxLogEntries)
_logs.RemoveRange(0, _logs.Count - _maxLogEntries + 1);
_logs.Add(new LogEntry(
DateTime.Now,
prefix ?? string.Empty,
color,
msg ?? string.Empty
));
}
}
// ---- UI lifecycle ----
/// <summary>
/// Starts the live TUI loop. This method blocks until the cancellation token is triggered.
/// </summary>
public Task RunAsync(CancellationToken cancellationToken = default)
{
if (_uiTask != null)
throw new InvalidOperationException("UI already started.");
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, cancellationToken);
var token = linkedCts.Token;
_uiTask = Task.Run(() => RunUiAsync(token), token);
_inputTask = Task.Run(() => RunInputLoopAsync(token), token);
return _uiTask;
}
private async Task RunUiAsync(CancellationToken token)
{
await AnsiConsole.Live(BuildRoot())
.AutoClear(false)
.StartAsync(async ctx =>
{
while (!token.IsCancellationRequested)
{
try
{
ctx.UpdateTarget(BuildRoot());
await Task.Delay(100, token);
}
catch ( Exception )
{
break;
}
}
});
}
private async Task RunInputLoopAsync(CancellationToken token)
{
// Dummy handler for Cancel button: press 'c' or 'C'
while (!token.IsCancellationRequested)
{
if (Console.KeyAvailable)
{
var key = Console.ReadKey(intercept: true);
if (key.KeyChar == 'c' || key.KeyChar == 'C')
{
((ILogger)this).LogWarn("Cancel button pressed (dummy handler).");
}
}
await Task.Delay(50, token).ConfigureAwait(false);
}
}
// ---- Rendering ----
private IRenderable BuildRoot()
{
List<ProgressEntry> progressSnapshot;
List<LogEntry> logSnapshot;
string titleSnapshot;
int numberOfProcessesSnapshot;
lock (_sync)
{
titleSnapshot = Title;
numberOfProcessesSnapshot = NumberOfProcesses;
progressSnapshot = Enumerable.Range(0, numberOfProcessesSnapshot)
.Select(i => _progress.TryGetValue(i, out var p) ? p : ProgressEntry.Empty)
.ToList();
logSnapshot = _logs.ToList();
}
var layout = new Layout("root")
.SplitRows(
new Layout("progress") { Size = Math.Max(3, numberOfProcessesSnapshot + 4) },
new Layout("log")
//new Layout("buttons") { Size = 3 }
);
layout["progress"].Update(BuildProgressPanel(progressSnapshot));
layout["log"].Update(BuildLogPanel(logSnapshot));
//layout["buttons"].Update(BuildButtonsPanel());
return layout;
}
private static IRenderable BuildProgressPanel(IReadOnlyList<ProgressEntry> entries)
{
var table = new Table()
.Expand()
.Border(TableBorder.Rounded)
.AddColumn("[bold]Name[/]")
.AddColumn("[bold]Progress[/]")
.AddColumn("[bold]%[/]")
.AddColumn("[bold]ETA[/]")
.AddColumn("[bold]FPS[/]");
foreach (var entry in entries)
{
var bar = new DynamicGradientBar(entry.Progress);
var percentText = $"{entry.Progress * 100:0.0}%";
var etaText = entry.Eta == TimeSpan.Zero
? "--:--"
: $"{(int)entry.Eta.TotalMinutes:00}:{entry.Eta.Seconds:00}";
var fpsText = entry.Speed <= 0 ? "-" : $"{entry.Speed:0.0}";
table.AddRow(
new Markup(Escape(entry.Name)),
bar,
new Markup(percentText),
new Markup(etaText),
new Markup(fpsText)
);
}
return table;
}
private static IRenderable BuildLogPanel(IReadOnlyList<LogEntry> logs)
{
const int maxVisible = 200;
var slice = logs.Count > maxVisible
? logs.Skip(logs.Count - maxVisible).ToList()
: logs.ToList();
var rows = new List<IRenderable>();
foreach (var log in slice)
{
var time = log.Timestamp.ToString("HH:mm:ss");
var timeColor = "deepskyblue1";
var prefixColor = "lightpink1";
var msgColor = MapConsoleColor(log.Color);
var line =
$"[{timeColor}]{Escape(time)}[/] " +
$"[{prefixColor}]{Escape(log.Prefix)}[/] " +
$"[{msgColor}]{Escape(log.Message)}[/]";
rows.Add(new Markup(line));
}
if (rows.Count == 0)
return new Markup("[grey]No log messages yet.[/]");
return new Rows(rows);
}
// ---- Helpers ----
private static string Escape(string value) =>
value is null ? string.Empty : Markup.Escape(value);
private static string MapConsoleColor(ConsoleColor color) =>
color switch
{
ConsoleColor.Black => "black",
ConsoleColor.DarkBlue => "navy",
ConsoleColor.DarkGreen => "green",
ConsoleColor.DarkCyan => "teal",
ConsoleColor.DarkRed => "maroon",
ConsoleColor.DarkMagenta => "purple",
ConsoleColor.DarkYellow => "olive",
ConsoleColor.Gray => "silver",
ConsoleColor.DarkGray => "grey",
ConsoleColor.Blue => "blue",
ConsoleColor.Green => "lime",
ConsoleColor.Cyan => "aqua",
ConsoleColor.Red => "red",
ConsoleColor.Magenta => "fuchsia",
ConsoleColor.Yellow => "yellow",
ConsoleColor.White => "white",
_ => "white"
};
/// <summary>
/// Renders a horizontal gradient bar (blue → yellow → green) for the given progress [0..1].
/// </summary>
private static string RenderGradientBar(double progress, int width)
{
progress = Math.Clamp(progress, 0.0, 1.0);
if (width <= 0)
return string.Empty;
var filled = (int)Math.Round(progress * width);
var empty = width - filled;
if (filled <= 0)
return $"[grey]{new string('─', width)}[/]";
// Split filled part into three segments: blue / yellow / green
// low progress: mostly blue; mid: yellow; high: green
var blueCount = (int)Math.Round(filled * 0.33);
var yellowCount = (int)Math.Round(filled * 0.34);
var greenCount = filled - blueCount - yellowCount;
var sb = new StringBuilder();
if (blueCount > 0)
{
sb.Append("[blue]");
sb.Append(new string('█', blueCount));
sb.Append("[/]");
}
if (yellowCount > 0)
{
sb.Append("[yellow]");
sb.Append(new string('█', yellowCount));
sb.Append("[/]");
}
if (greenCount > 0)
{
sb.Append("[green]");
sb.Append(new string('█', greenCount));
sb.Append("[/]");
}
if (empty > 0)
{
sb.Append("[grey]");
sb.Append(new string('─', empty));
sb.Append("[/]");
}
return sb.ToString();
}
// ---- Types & disposal ----
private readonly record struct ProgressEntry(
string Name,
double Progress,
TimeSpan Eta,
double Speed)
{
public static ProgressEntry Empty => new(string.Empty, 0.0, TimeSpan.Zero, 0.0);
}
private readonly record struct LogEntry(
DateTime Timestamp,
string Prefix,
ConsoleColor Color,
string Message);
public void Dispose()
{
_cts.Cancel();
try
{
_uiTask?.Wait(500);
_inputTask?.Wait(500);
}
catch
{
// ignore
}
_cts.Dispose();
}
private sealed class DynamicGradientBar : IRenderable
{
private readonly double _progress;
public DynamicGradientBar(double progress)
{
_progress = Math.Clamp(progress, 0, 1);
}
public Measurement Measure(RenderOptions options, int maxWidth)
{
// Use the full width Spectre gives us
var width = Math.Max(1, maxWidth);
return new Measurement(width, width);
}
public IEnumerable<Segment> Render(RenderOptions options, int maxWidth)
{
var width = Math.Max(1, maxWidth);
// Your gradient bar string WITH markup
var bar = RenderGradientBar(_progress, width);
// Wrap it in a Markup renderable
var markup = new Markup(bar);
// Correct: delegate rendering to Markup
foreach (var segment in ((IRenderable)markup).Render(options, maxWidth))
yield return segment;
}
}
}