Files
Scripts/hanatui/src/Tui/TaskRunnerScreen.cs
T

268 lines
9.8 KiB
C#

using HanaTui.System;
using HanaTui.Tui.Components;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace HanaTui.Tui;
/// <summary>
/// Runs an operation while showing a live split-panel:
/// Left: System stats (CPU, RAM, Swap, elapsed)
/// Right: Streaming operation log
///
/// Abort: first Q warns, second Q within 3s sends SIGTERM then SIGKILL.
/// Post-completion: 10s countdown, any key cancels and holds; Enter/Esc returns.
/// </summary>
public static class TaskRunnerScreen
{
private const int CountdownSeconds = 10;
private const double AbortWindowSeconds = 3.0;
public static async Task RunAsync(
string operationTitle,
Func<Action<string>, CancellationToken, Task<bool>> operation)
{
AnsiConsole.Clear();
var logPanel = new LogPanel();
var stats = new SystemStats();
var cts = new CancellationTokenSource();
var startTime = DateTime.Now;
var operationDone = false;
var operationSuccess = false;
DateTime? firstQTime = null;
// Key listener
var keyTask = Task.Run(async () =>
{
while (!operationDone)
{
if (Console.KeyAvailable)
{
var key = Console.ReadKey(intercept: true);
if (key.Key == ConsoleKey.Q || key.KeyChar is 'q' or 'Q')
{
if (cts.IsCancellationRequested) break;
if (firstQTime is null)
{
firstQTime = DateTime.Now;
logPanel.Add("[WARN] Press Q again within 3 seconds to abort.");
}
else if ((DateTime.Now - firstQTime.Value).TotalSeconds <= AbortWindowSeconds)
{
logPanel.Add("[WARN] Aborting operation...");
await cts.CancelAsync();
}
else
{
firstQTime = DateTime.Now;
logPanel.Add("[WARN] Press Q again within 3 seconds to abort.");
}
}
}
await Task.Delay(50, CancellationToken.None);
}
}, CancellationToken.None);
// Operation task
var operationTask = Task.Run(async () =>
{
try
{
operationSuccess = await operation(line => logPanel.Add(line), cts.Token);
}
catch (Exception ex)
{
logPanel.Add($"[ERROR] Unexpected exception: {ex.Message}");
operationSuccess = false;
}
finally
{
operationDone = true;
}
}, CancellationToken.None);
// Live render loop
await AnsiConsole.Live(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running))
.AutoClear(false)
.StartAsync(async ctx =>
{
while (!operationDone)
{
ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running));
await Task.Delay(800, CancellationToken.None);
}
var elapsed = DateTime.Now - startTime;
RunState finalState;
if (cts.IsCancellationRequested)
{
finalState = RunState.Aborted;
logPanel.Add($"[WARN] Operation aborted after {FormatElapsed(elapsed)}.");
}
else if (operationSuccess)
{
finalState = RunState.Success;
logPanel.Add($"[DONE] Operation completed in {FormatElapsed(elapsed)}.");
}
else
{
finalState = RunState.Failed;
logPanel.Add($"[ERR ] Operation failed after {FormatElapsed(elapsed)}.");
}
ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, finalState));
});
stats.Dispose();
await operationTask;
operationDone = true;
await keyTask;
await PostCompletionWaitAsync(operationTitle, logPanel, stats, startTime,
cts.IsCancellationRequested ? RunState.Aborted :
operationSuccess ? RunState.Success : RunState.Failed);
}
// -----------------------------------------------------------------------
private enum RunState { Running, Success, Failed, Aborted }
private static async Task PostCompletionWaitAsync(
string title, LogPanel logPanel, SystemStats stats, DateTime startTime, RunState state)
{
var countdownCancelled = false;
var returnNow = false;
var staying = false;
var startCountdown = DateTime.Now;
var countdownTask = Task.Run(async () =>
{
for (int i = CountdownSeconds; i > 0 && !countdownCancelled; i--)
await Task.Delay(1000, CancellationToken.None);
if (!countdownCancelled) returnNow = true;
}, CancellationToken.None);
await AnsiConsole.Live(BuildLayout(title, logPanel, stats, startTime, state))
.AutoClear(false)
.StartAsync(async ctx =>
{
while (!returnNow)
{
if (Console.KeyAvailable)
{
var key = Console.ReadKey(intercept: true);
countdownCancelled = true;
if (key.Key is ConsoleKey.Enter or ConsoleKey.Escape)
{
returnNow = true;
break;
}
if (!staying)
{
staying = true;
logPanel.Add("[INFO] Countdown cancelled. Press Enter or Esc to return to menu.");
}
}
string footerText;
if (!staying)
{
var secsLeft = Math.Max(0, CountdownSeconds -
(int)(DateTime.Now - startCountdown).TotalSeconds);
footerText = $"Returning to menu in {secsLeft}s... (any key to stay)";
}
else
{
footerText = "Press Enter or Esc to return to menu.";
}
ctx.UpdateTarget(BuildLayout(title, logPanel, stats, startTime, state, footerText));
await Task.Delay(200, CancellationToken.None);
}
});
await countdownTask;
}
// -----------------------------------------------------------------------
// Layout
// -----------------------------------------------------------------------
private static IRenderable BuildLayout(
string title,
LogPanel logPanel,
SystemStats stats,
DateTime startTime,
RunState state,
string? footerText = null)
{
var elapsed = DateTime.Now - startTime;
var snap = stats.CurrentSnapshot;
const int statsPanelWidth = 40;
var statsRenderable = StatsPanel.Build(snap, elapsed, statsPanelWidth - 4);
var logRenderable = logPanel.Build();
var layout = new Layout("root").SplitColumns(
new Layout("stats").Size(statsPanelWidth),
new Layout("log"));
layout["stats"].Update(statsRenderable);
layout["log"].Update(logRenderable);
var outerGrid = new Grid();
outerGrid.AddColumn(new GridColumn());
// 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);
// 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 IRenderable StateLabel(RunState state) => state switch
{
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";
return $"{t.Seconds}s";
}
}