diff --git a/hanatui/publish/hanatui b/hanatui/publish/hanatui index 8a8abd0..35a9bf1 100755 Binary files a/hanatui/publish/hanatui and b/hanatui/publish/hanatui differ diff --git a/hanatui/src/Tui/Components/LogPanel.cs b/hanatui/src/Tui/Components/LogPanel.cs index 2569e78..aa25400 100644 --- a/hanatui/src/Tui/Components/LogPanel.cs +++ b/hanatui/src/Tui/Components/LogPanel.cs @@ -3,27 +3,23 @@ using Spectre.Console.Rendering; namespace HanaTui.Tui.Components; -/// -/// A timestamped log entry. -/// public sealed class LogEntry { - public DateTime Time { get; init; } = DateTime.Now; - public string Text { get; init; } = ""; + public DateTime Time { get; init; } = DateTime.Now; + public string Text { get; init; } = ""; public LogLevel Level { get; init; } = LogLevel.Info; } public enum LogLevel { Info, Sql, Done, Warn, Error } /// -/// Maintains a bounded list of log entries and renders them as a -/// Spectre.Console IRenderable panel, showing the most recent N lines. -/// Thread-safe. +/// Thread-safe bounded log. Build() returns an IRenderable panel. +/// Dynamic content is rendered via Text() objects — never interpolated into markup strings. /// public sealed class LogPanel { - private const int MaxEntries = 500; - private const int VisibleLines = 20; // shown inside the panel + private const int MaxEntries = 500; + private const int VisibleLines = 20; private readonly List _entries = new(MaxEntries); private readonly object _lock = new(); @@ -31,19 +27,13 @@ public sealed class LogPanel public void Add(string text) { var level = DetectLevel(text); - var entry = new LogEntry { Time = DateTime.Now, Text = text, Level = level }; - lock (_lock) { - if (_entries.Count >= MaxEntries) - _entries.RemoveAt(0); - _entries.Add(entry); + if (_entries.Count >= MaxEntries) _entries.RemoveAt(0); + _entries.Add(new LogEntry { Time = DateTime.Now, Text = text, Level = level }); } } - /// - /// Returns a renderable panel showing the last N log lines. - /// public IRenderable Build() { List snapshot; @@ -56,30 +46,37 @@ public sealed class LogPanel var rows = new Grid(); rows.AddColumn(new GridColumn().NoWrap()); - // Pad to VisibleLines so the panel doesn't resize each tick - var padCount = VisibleLines - snapshot.Count; - for (int i = 0; i < padCount; i++) + // Pad so the panel height stays stable + for (int i = 0; i < VisibleLines - snapshot.Count; i++) rows.AddRow(new Text("")); foreach (var entry in snapshot) { - var timeStr = entry.Time.ToString("HH:mm:ss"); - var (tagLabel, color) = entry.Level switch + var (tagLabel, tagColor, textColor) = entry.Level switch { - LogLevel.Sql => ("SQL ", "blue"), - LogLevel.Done => ("DONE", "green"), - LogLevel.Warn => ("WARN", "yellow"), - LogLevel.Error => ("ERR ", "red"), - _ => ("INFO", "grey"), + LogLevel.Sql => ("SQL ", Color.Blue, Color.White), + LogLevel.Done => ("DONE", Color.Green, Color.White), + LogLevel.Warn => ("WARN", Color.Yellow, Color.White), + LogLevel.Error => ("ERR ", Color.Red, Color.White), + _ => ("INFO", Color.Grey, Color.Silver), }; - // Strip the leading [TAG] prefix from the raw text if present, - // since we re-render it with color. Then escape the remainder. - var rawText = StripKnownPrefix(entry.Text); - var safeText = Markup.Escape(rawText); + // Strip the [TAG] prefix the service already prepends — we re-render it with color + var bodyText = StripKnownPrefix(entry.Text); - rows.AddRow(new Markup( - $"[dim]{timeStr}[/] [{color}][[{tagLabel}]][/] {safeText}")); + // Compose the row from Text objects — zero markup parsing of dynamic content + var lineGrid = new Grid(); + lineGrid.AddColumn(new GridColumn().NoWrap().Width(10)); // time + lineGrid.AddColumn(new GridColumn().NoWrap().Width(8)); // tag + lineGrid.AddColumn(new GridColumn().NoWrap()); // body + + lineGrid.AddRow( + new Text(entry.Time.ToString("HH:mm:ss"), new Style(Color.Grey, decoration: Decoration.Dim)), + new Text($"[{tagLabel}]", new Style(tagColor)), + new Text(" " + bodyText, new Style(textColor)) + ); + + rows.AddRow(lineGrid); } return new Panel(rows) @@ -90,16 +87,15 @@ public sealed class LogPanel }; } + // ------------------------------------------------------------------------- + private static LogLevel DetectLevel(string text) { - if (text.Contains("[SQL ]") || text.Contains("[SQL]")) - return LogLevel.Sql; - if (text.Contains("[DONE]")) - return LogLevel.Done; - if (text.Contains("[WARN]")) - return LogLevel.Warn; - if (text.Contains("[ERROR]") || text.Contains("[ERR]") || text.Contains("[ERR ]")) - return LogLevel.Error; + if (text.Contains("[SQL ]") || text.Contains("[SQL]")) return LogLevel.Sql; + if (text.Contains("[DONE]")) return LogLevel.Done; + if (text.Contains("[WARN]")) return LogLevel.Warn; + if (text.Contains("[ERROR]") || text.Contains("[ERR ]") || text.Contains("[ERR]")) + return LogLevel.Error; return LogLevel.Info; } @@ -108,11 +104,9 @@ public sealed class LogPanel private static string StripKnownPrefix(string text) { - foreach (var prefix in KnownPrefixes) - { - if (text.StartsWith(prefix, StringComparison.Ordinal)) - return text[prefix.Length..]; - } + foreach (var p in KnownPrefixes) + if (text.StartsWith(p, StringComparison.Ordinal)) + return text[p.Length..]; return text; } } diff --git a/hanatui/src/Tui/Components/StatsPanel.cs b/hanatui/src/Tui/Components/StatsPanel.cs index 7e2255a..31e0408 100644 --- a/hanatui/src/Tui/Components/StatsPanel.cs +++ b/hanatui/src/Tui/Components/StatsPanel.cs @@ -6,21 +6,18 @@ namespace HanaTui.Tui.Components; /// /// Renders the system stats panel (CPU bars, RAM bar, Swap bar, elapsed time). -/// Returns a Spectre.Console IRenderable so it can be embedded in a Live layout. +/// Uses only hardcoded markup tags — no dynamic data ever enters a markup string. /// public static class StatsPanel { - /// - /// Build the stats panel renderable from the current snapshot. - /// public static IRenderable Build(SystemSnapshot snap, TimeSpan elapsed, int panelWidth) { - var barWidth = Math.Max(10, panelWidth - 16); // leave room for label + % + var barWidth = Math.Max(10, panelWidth - 16); var grid = new Grid(); grid.AddColumn(new GridColumn().NoWrap()); - // --- CPU Section --- + // --- CPU --- grid.AddRow(new Markup("[bold yellow]CPU[/]")); if (snap.CpuPercents.Length == 0) @@ -34,23 +31,21 @@ public static class StatsPanel var label = snap.CpuLabels.Length > i ? snap.CpuLabels[i] : $"cpu{i}"; var displayLabel = label == "cpu" ? "Total" : label.Replace("cpu", "Core"); var pct = snap.CpuPercents[i]; - var bar = BuildBar(pct, barWidth, CpuColor(pct)); - grid.AddRow(new Markup($" [dim]{displayLabel,-6}[/] {bar} [bold]{pct,5:F1}%[/]")); + // Build as Columns: label | bar | pct — no interpolation of dynamic values into markup + grid.AddRow(BuildBarRow(displayLabel, pct, barWidth)); } } grid.AddRow(new Text("")); - // --- Memory Section --- + // --- Memory --- grid.AddRow(new Markup("[bold cyan]MEMORY[/]")); if (snap.MemTotalKb > 0) { var memPct = snap.MemUsedPercent; - var memBar = BuildBar(memPct, barWidth, MemColor(memPct)); - grid.AddRow(new Markup( - $" [dim]Used [/] {memBar} [bold]{memPct,5:F1}%[/]")); - grid.AddRow(new Markup( - $" [dim]{SystemSnapshot.FormatGb(snap.MemUsedKb)} / {SystemSnapshot.FormatGb(snap.MemTotalKb)}[/]")); + grid.AddRow(BuildBarRow("Used", memPct, barWidth)); + grid.AddRow(new Text($" {SystemSnapshot.FormatGb(snap.MemUsedKb)} / {SystemSnapshot.FormatGb(snap.MemTotalKb)}", + new Style(Color.Grey))); } else { @@ -59,16 +54,14 @@ public static class StatsPanel grid.AddRow(new Text("")); - // --- Swap Section --- + // --- Swap --- grid.AddRow(new Markup("[bold cyan]SWAP[/]")); if (snap.SwapTotalKb > 0) { var swapPct = snap.SwapUsedPercent; - var swapBar = BuildBar(swapPct, barWidth, MemColor(swapPct)); - grid.AddRow(new Markup( - $" [dim]Used [/] {swapBar} [bold]{swapPct,5:F1}%[/]")); - grid.AddRow(new Markup( - $" [dim]{SystemSnapshot.FormatGb(snap.SwapUsedKb)} / {SystemSnapshot.FormatGb(snap.SwapTotalKb)}[/]")); + grid.AddRow(BuildBarRow("Used", swapPct, barWidth)); + grid.AddRow(new Text($" {SystemSnapshot.FormatGb(snap.SwapUsedKb)} / {SystemSnapshot.FormatGb(snap.SwapTotalKb)}", + new Style(Color.Grey))); } else { @@ -78,17 +71,21 @@ public static class StatsPanel grid.AddRow(new Text("")); // --- Elapsed --- - grid.AddRow(new Markup( - $"[bold]Elapsed:[/] [yellow]{FormatElapsed(elapsed)}[/]")); + // Two separate renderables composed — no interpolation of elapsed into markup + var elapsedGrid = new Grid(); + elapsedGrid.AddColumn(new GridColumn().NoWrap()); + elapsedGrid.AddColumn(new GridColumn().NoWrap()); + elapsedGrid.AddRow( + new Markup("[bold]Elapsed:[/]"), + new Text(" " + FormatElapsed(elapsed), new Style(Color.Yellow))); + grid.AddRow(elapsedGrid); - var panel = new Panel(grid) + return new Panel(grid) { Header = new PanelHeader("[bold] SYSTEM STATS [/]"), Border = BoxBorder.Rounded, Padding = new Padding(1, 0), }; - - return panel; } // ------------------------------------------------------------------------- @@ -96,36 +93,46 @@ public static class StatsPanel // ------------------------------------------------------------------------- /// - /// Renders a horizontal block bar of the given width. - /// e.g. [████████░░░░] - /// Result is a markup string safe for embedding inside a larger Markup call. + /// Builds a single bar row as a Grid with three columns: + /// label (dim) | bar (block chars, plain Text with color) | pct (bold) + /// Nothing here goes through markup parsing with dynamic content. /// - private static string BuildBar(double percent, int width, string color) + private static IRenderable BuildBarRow(string label, double pct, int barWidth) { - var filled = (int)Math.Round(percent / 100.0 * width); - filled = Math.Clamp(filled, 0, width); - var empty = width - filled; + var filled = (int)Math.Round(pct / 100.0 * barWidth); + filled = Math.Clamp(filled, 0, barWidth); + var empty = barWidth - filled; - // \u2588 = █ \u2591 = ░ - // [[ and ]] are Spectre escape sequences for literal [ and ] + var filledColor = CpuColor(pct); var filledStr = new string('\u2588', filled); var emptyStr = new string('\u2591', empty); - return $"[[[{color}]{filledStr}[/][dim]{emptyStr}[/]]]"; + + var row = new Grid(); + row.AddColumn(new GridColumn().NoWrap().Width(9)); // label + row.AddColumn(new GridColumn().NoWrap()); // bar chars + row.AddColumn(new GridColumn().NoWrap().Width(7)); // pct + + row.AddRow( + new Text($" {label,-5}", new Style(Color.Grey, decoration: Decoration.Dim)), + // Bar: colored filled + dim empty, wrapped in plain Text objects side by side + new Columns( + new Text("[", new Style(Color.Grey)), + new Text(filledStr, new Style(filledColor)), + new Text(emptyStr, new Style(Color.Grey, decoration: Decoration.Dim)), + new Text("]", new Style(Color.Grey)) + ), + new Text($" {pct,5:F1}%", new Style(Color.White, decoration: Decoration.Bold)) + ); + + return row; } - private static string CpuColor(double pct) => pct switch + private static Color CpuColor(double pct) => pct switch { - > 90 => "red", - > 70 => "yellow", - > 40 => "green", - _ => "blue", - }; - - private static string MemColor(double pct) => pct switch - { - > 90 => "red", - > 70 => "yellow", - _ => "cyan", + > 90 => Color.Red, + > 70 => Color.Yellow, + > 40 => Color.Green, + _ => Color.Blue, }; private static string FormatElapsed(TimeSpan t) diff --git a/hanatui/src/Tui/TaskRunnerScreen.cs b/hanatui/src/Tui/TaskRunnerScreen.cs index 56c2944..a787dbb 100644 --- a/hanatui/src/Tui/TaskRunnerScreen.cs +++ b/hanatui/src/Tui/TaskRunnerScreen.cs @@ -10,19 +10,13 @@ namespace HanaTui.Tui; /// Left: System stats (CPU, RAM, Swap, elapsed) /// Right: Streaming operation log /// -/// Abort behavior: -/// First Q -> warning shown in log -/// Second Q (within 3s) -> sends cancellation (SIGTERM -> wait 5s -> SIGKILL) -/// -/// Post-completion: -/// Stats freeze, log stays. -/// "Returning in 10s... [any key to stay]" countdown. -/// If key pressed: stays until Enter/Esc. +/// Abort: first Q warns, second Q within 3s sends SIGTERM then SIGKILL. +/// Post-completion: 10s countdown, any key cancels and holds; Enter/Esc returns. /// public static class TaskRunnerScreen { - private const int CountdownSeconds = 10; - private const double AbortWindowSeconds = 3.0; + private const int CountdownSeconds = 10; + private const double AbortWindowSeconds = 3.0; public static async Task RunAsync( string operationTitle, @@ -30,20 +24,16 @@ public static class TaskRunnerScreen { AnsiConsole.Clear(); - var logPanel = new LogPanel(); - var stats = new SystemStats(); - var cts = new CancellationTokenSource(); + var logPanel = new LogPanel(); + var stats = new SystemStats(); + var cts = new CancellationTokenSource(); var startTime = DateTime.Now; - var operationDone = false; + var operationDone = false; var operationSuccess = false; + DateTime? firstQTime = null; - // Abort state machine - DateTime? firstQPressTime = null; - - // ----------------------------------------------------------------------- - // Key listener task — runs concurrently with the Live render loop - // ----------------------------------------------------------------------- + // Key listener var keyTask = Task.Run(async () => { while (!operationDone) @@ -51,25 +41,24 @@ public static class TaskRunnerScreen if (Console.KeyAvailable) { var key = Console.ReadKey(intercept: true); - if (key.Key == ConsoleKey.Q || key.KeyChar == 'q' || key.KeyChar == 'Q') + if (key.Key == ConsoleKey.Q || key.KeyChar is 'q' or 'Q') { - if (cts.IsCancellationRequested) break; // already cancelled + if (cts.IsCancellationRequested) break; - if (firstQPressTime is null) + if (firstQTime is null) { - firstQPressTime = DateTime.Now; - logPanel.Add("[WARN] Press Q again within 3 seconds to abort the operation."); + firstQTime = DateTime.Now; + logPanel.Add("[WARN] Press Q again within 3 seconds to abort."); } - else if ((DateTime.Now - firstQPressTime.Value).TotalSeconds <= AbortWindowSeconds) + else if ((DateTime.Now - firstQTime.Value).TotalSeconds <= AbortWindowSeconds) { logPanel.Add("[WARN] Aborting operation..."); await cts.CancelAsync(); } else { - // Window expired, treat as first press again - firstQPressTime = DateTime.Now; - logPanel.Add("[WARN] Press Q again within 3 seconds to abort the operation."); + firstQTime = DateTime.Now; + logPanel.Add("[WARN] Press Q again within 3 seconds to abort."); } } } @@ -77,16 +66,12 @@ public static class TaskRunnerScreen } }, CancellationToken.None); - // ----------------------------------------------------------------------- - // Main operation task - // ----------------------------------------------------------------------- + // Operation task var operationTask = Task.Run(async () => { try { - operationSuccess = await operation( - line => logPanel.Add(line), - cts.Token); + operationSuccess = await operation(line => logPanel.Add(line), cts.Token); } catch (Exception ex) { @@ -99,93 +84,77 @@ public static class TaskRunnerScreen } }, CancellationToken.None); - // ----------------------------------------------------------------------- // Live render loop - // ----------------------------------------------------------------------- - await AnsiConsole.Live(BuildLayout(logPanel, stats, startTime, "[yellow]Running...[/]")) + await AnsiConsole.Live(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running)) .AutoClear(false) .StartAsync(async ctx => { while (!operationDone) { - var elapsed = DateTime.Now - startTime; - ctx.UpdateTarget( - BuildLayout(logPanel, stats, startTime, "[yellow]Running...[/]")); + ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running)); await Task.Delay(800, CancellationToken.None); } - // Final render with result - var elapsed2 = DateTime.Now - startTime; - var statusMsg = cts.IsCancellationRequested - ? "[red]Aborted[/]" - : operationSuccess - ? "[green]Completed successfully[/]" - : "[red]Failed[/]"; - + var elapsed = DateTime.Now - startTime; + RunState finalState; if (cts.IsCancellationRequested) - logPanel.Add($"[WARN] Operation aborted after {FormatElapsed(elapsed2)}."); + { + finalState = RunState.Aborted; + logPanel.Add($"[WARN] Operation aborted after {FormatElapsed(elapsed)}."); + } else if (operationSuccess) - logPanel.Add($"[DONE] Operation completed in {FormatElapsed(elapsed2)}."); + { + finalState = RunState.Success; + logPanel.Add($"[DONE] Operation completed in {FormatElapsed(elapsed)}."); + } else - logPanel.Add($"[ERR ] Operation failed after {FormatElapsed(elapsed2)}."); + { + finalState = RunState.Failed; + logPanel.Add($"[ERR ] Operation failed after {FormatElapsed(elapsed)}."); + } - ctx.UpdateTarget(BuildLayout(logPanel, stats, startTime, statusMsg)); + ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, finalState)); }); - // Clean up stats.Dispose(); - await operationTask; // ensure it's fully done + await operationTask; operationDone = true; await keyTask; - // ----------------------------------------------------------------------- - // Post-completion: countdown or hold - // ----------------------------------------------------------------------- - await PostCompletionWaitAsync(logPanel, stats, startTime, operationSuccess, cts.IsCancellationRequested); + await PostCompletionWaitAsync(operationTitle, logPanel, stats, startTime, + cts.IsCancellationRequested ? RunState.Aborted : + operationSuccess ? RunState.Success : RunState.Failed); } // ----------------------------------------------------------------------- - // Post-completion hold screen - // ----------------------------------------------------------------------- + + private enum RunState { Running, Success, Failed, Aborted } private static async Task PostCompletionWaitAsync( - LogPanel logPanel, - SystemStats stats, - DateTime startTime, - bool success, - bool aborted) + string title, LogPanel logPanel, SystemStats stats, DateTime startTime, RunState state) { - var statusMsg = aborted ? "[red]Aborted[/]" : - success ? "[green]Completed[/]" : "[red]Failed[/]"; - var countdownCancelled = false; var returnNow = false; + var staying = false; + var startCountdown = DateTime.Now; - // Start 10-second countdown in background var countdownTask = Task.Run(async () => { for (int i = CountdownSeconds; i > 0 && !countdownCancelled; i--) - { await Task.Delay(1000, CancellationToken.None); - } - if (!countdownCancelled) - returnNow = true; + if (!countdownCancelled) returnNow = true; }, CancellationToken.None); - await AnsiConsole.Live(BuildLayout(logPanel, stats, startTime, statusMsg)) + await AnsiConsole.Live(BuildLayout(title, logPanel, stats, startTime, state)) .AutoClear(false) .StartAsync(async ctx => { - var secondsLeft = CountdownSeconds; - var staying = false; - while (!returnNow) { if (Console.KeyAvailable) { var key = Console.ReadKey(intercept: true); countdownCancelled = true; - staying = true; if (key.Key is ConsoleKey.Enter or ConsoleKey.Escape) { @@ -193,27 +162,26 @@ public static class TaskRunnerScreen break; } - // Any other key: cancel countdown, show "hold" message - logPanel.Add("[INFO] Countdown cancelled. Press [Enter] or [Esc] to return to menu."); + if (!staying) + { + staying = true; + logPanel.Add("[INFO] Countdown cancelled. Press Enter or Esc to return to menu."); + } } - // Update footer countdown - var elapsed = DateTime.Now - startTime; - string footer; + string footerText; if (!staying) { - secondsLeft = CountdownSeconds - - (int)(DateTime.Now - startTime).TotalSeconds; // approximate - // recompute properly - footer = $"[dim]Returning to menu in {Math.Max(0, secondsLeft)}s... " + - "[any key to stay][/]"; + var secsLeft = Math.Max(0, CountdownSeconds - + (int)(DateTime.Now - startCountdown).TotalSeconds); + footerText = $"Returning to menu in {secsLeft}s... (any key to stay)"; } else { - footer = "[dim]Press [Enter] or [Esc] to return to menu.[/]"; + footerText = "Press Enter or Esc to return to menu."; } - ctx.UpdateTarget(BuildLayoutWithFooter(logPanel, stats, startTime, statusMsg, footer)); + ctx.UpdateTarget(BuildLayout(title, logPanel, stats, startTime, state, footerText)); await Task.Delay(200, CancellationToken.None); } }); @@ -222,74 +190,78 @@ public static class TaskRunnerScreen } // ----------------------------------------------------------------------- - // Layout builders + // Layout // ----------------------------------------------------------------------- private static IRenderable BuildLayout( + string title, LogPanel logPanel, SystemStats stats, DateTime startTime, - string statusMsg) - { - return BuildLayoutWithFooter(logPanel, stats, startTime, statusMsg, footer: null); - } - - private static IRenderable BuildLayoutWithFooter( - LogPanel logPanel, - SystemStats stats, - DateTime startTime, - string statusMsg, - string? footer) + RunState state, + string? footerText = null) { var elapsed = DateTime.Now - startTime; - var snap = stats.CurrentSnapshot; + var snap = stats.CurrentSnapshot; - // Stats panel (left) — fixed width ~36 chars - var statsPanelWidth = 36; + const int statsPanelWidth = 40; var statsRenderable = StatsPanel.Build(snap, elapsed, statsPanelWidth - 4); + var logRenderable = logPanel.Build(); - // Log panel (right) — fills remaining space - var logRenderable = logPanel.Build(); - - var layout = new Layout("root") - .SplitColumns( - new Layout("stats").Size(statsPanelWidth), - new Layout("log")); - + var layout = new Layout("root").SplitColumns( + new Layout("stats").Size(statsPanelWidth), + new Layout("log")); layout["stats"].Update(statsRenderable); layout["log"].Update(logRenderable); - // Wrap in a grid so we can add a footer row var outerGrid = new Grid(); outerGrid.AddColumn(new GridColumn()); - // Title row - outerGrid.AddRow(new Markup( - $"[bold dodgerblue1] Running:[/] [yellow]{Markup.Escape(ExtractTitle(statusMsg))}[/] " + - $"Status: {statusMsg}")); - + // Title row — state label uses Text objects, not markup interpolation + var titleGrid = new Grid(); + titleGrid.AddColumn(new GridColumn().NoWrap()); + titleGrid.AddColumn(new GridColumn().NoWrap()); + titleGrid.AddColumn(new GridColumn().NoWrap()); + titleGrid.AddColumn(new GridColumn().NoWrap()); + titleGrid.AddRow( + new Markup("[bold dodgerblue1] Operation:[/]"), + new Text(" " + title, new Style(Color.Yellow)), + new Markup(" [bold dodgerblue1]Status:[/]"), + StateLabel(state) + ); + outerGrid.AddRow(titleGrid); outerGrid.AddRow(layout); - if (footer is not null) - outerGrid.AddRow(new Markup($"\n {footer} [dim][[Q]] Abort[/]")); - else - outerGrid.AddRow(new Markup("\n [dim][[Q]] Press once to warn, twice to abort[/]")); + // Footer row — plain Text, no markup parsing of dynamic content + var footerGrid = new Grid(); + footerGrid.AddColumn(new GridColumn().NoWrap()); + footerGrid.AddColumn(new GridColumn().NoWrap()); + + var footerLeft = footerText is not null + ? new Text(" " + footerText, new Style(Color.Grey, decoration: Decoration.Dim)) + : (IRenderable)new Text(" Press Q to abort (twice within 3s)", new Style(Color.Grey, decoration: Decoration.Dim)); + + var footerRight = new Text(" [Q] abort", new Style(Color.Grey, decoration: Decoration.Dim)); + + footerGrid.AddRow(footerLeft, footerRight); + outerGrid.AddRow(footerGrid); return outerGrid; } - private static string ExtractTitle(string statusMsg) + private static IRenderable StateLabel(RunState state) => state switch { - // statusMsg is markup like "[yellow]Running...[/]" — we just return a static title - return "HANA Operation"; - } + RunState.Running => new Markup(" [yellow]Running...[/]"), + RunState.Success => new Markup(" [green]Completed[/]"), + RunState.Failed => new Markup(" [red]Failed[/]"), + RunState.Aborted => new Markup(" [red]Aborted[/]"), + _ => new Text(""), + }; private static string FormatElapsed(TimeSpan t) { - if (t.TotalHours >= 1) - return $"{(int)t.TotalHours}h {t.Minutes:D2}m {t.Seconds:D2}s"; - if (t.TotalMinutes >= 1) - return $"{t.Minutes}m {t.Seconds:D2}s"; + if (t.TotalHours >= 1) return $"{(int)t.TotalHours}h {t.Minutes:D2}m {t.Seconds:D2}s"; + if (t.TotalMinutes >= 1) return $"{t.Minutes}m {t.Seconds:D2}s"; return $"{t.Seconds}s"; } }