using System.Text; using Spectre.Console; using Spectre.Console.Rendering; namespace splitter.tui; /// /// 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 /// public sealed class SpectreConsoleLogger : ILogger, IDisposable { private readonly object _sync = new(); private readonly List _logs = new(); private readonly Dictionary _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; /// /// Number of logical progress rows. UI reacts dynamically. /// public int NumberOfProcesses { get => _numberOfProcesses; set { lock (_sync) { _numberOfProcesses = Math.Max(1, value); for (int i = 0; i < _numberOfProcesses; i++) { if (!_progress.ContainsKey(i)) _progress[i] = ProgressEntry.Empty; } } } } // ---- ILogger ---- public void ClearProgress(int progressLevel) { lock (_sync) { _progress[progressLevel] = 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 ---- /// /// Starts the live TUI loop. This method blocks until the cancellation token is triggered. /// 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 progressSnapshot; List 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 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 logs) { const int maxVisible = 200; var slice = logs.Count > maxVisible ? logs.Skip(logs.Count - maxVisible).ToList() : logs.ToList(); var rows = new List(); 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" }; /// /// Renders a horizontal gradient bar (blue → yellow → green) for the given progress [0..1]. /// private static string RenderGradientBar(double progress, int width) { progress = Math.Clamp(progress, 0.0, 1.0); if (width <= 0) return string.Empty; int filled = (int)Math.Round(progress * width); int 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 int blueCount = (int)Math.Round(filled * 0.33); int yellowCount = (int)Math.Round(filled * 0.34); int 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 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; } } }