add hanatui

This commit is contained in:
2026-05-20 11:13:14 +02:00
parent bb8ac5de08
commit 27cf1b6314
19 changed files with 2520 additions and 1 deletions
+295
View File
@@ -0,0 +1,295 @@
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 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.
/// </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;
// Abort state machine
DateTime? firstQPressTime = null;
// -----------------------------------------------------------------------
// Key listener task — runs concurrently with the Live render loop
// -----------------------------------------------------------------------
var keyTask = Task.Run(async () =>
{
while (!operationDone)
{
if (Console.KeyAvailable)
{
var key = Console.ReadKey(intercept: true);
if (key.Key == ConsoleKey.Q || key.KeyChar == 'q' || key.KeyChar == 'Q')
{
if (cts.IsCancellationRequested) break; // already cancelled
if (firstQPressTime is null)
{
firstQPressTime = DateTime.Now;
logPanel.Add("[WARN] Press Q again within 3 seconds to abort the operation.");
}
else if ((DateTime.Now - firstQPressTime.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.");
}
}
}
await Task.Delay(50, CancellationToken.None);
}
}, CancellationToken.None);
// -----------------------------------------------------------------------
// Main 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(logPanel, stats, startTime, "[yellow]Running...[/]"))
.AutoClear(false)
.StartAsync(async ctx =>
{
while (!operationDone)
{
var elapsed = DateTime.Now - startTime;
ctx.UpdateTarget(
BuildLayout(logPanel, stats, startTime, "[yellow]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[/]";
if (cts.IsCancellationRequested)
logPanel.Add($"[WARN] Operation aborted after {FormatElapsed(elapsed2)}.");
else if (operationSuccess)
logPanel.Add($"[DONE] Operation completed in {FormatElapsed(elapsed2)}.");
else
logPanel.Add($"[ERR ] Operation failed after {FormatElapsed(elapsed2)}.");
ctx.UpdateTarget(BuildLayout(logPanel, stats, startTime, statusMsg));
});
// Clean up
stats.Dispose();
await operationTask; // ensure it's fully done
operationDone = true;
await keyTask;
// -----------------------------------------------------------------------
// Post-completion: countdown or hold
// -----------------------------------------------------------------------
await PostCompletionWaitAsync(logPanel, stats, startTime, operationSuccess, cts.IsCancellationRequested);
}
// -----------------------------------------------------------------------
// Post-completion hold screen
// -----------------------------------------------------------------------
private static async Task PostCompletionWaitAsync(
LogPanel logPanel,
SystemStats stats,
DateTime startTime,
bool success,
bool aborted)
{
var statusMsg = aborted ? "[red]Aborted[/]" :
success ? "[green]Completed[/]" : "[red]Failed[/]";
var countdownCancelled = false;
var returnNow = false;
// 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;
}, CancellationToken.None);
await AnsiConsole.Live(BuildLayout(logPanel, stats, startTime, statusMsg))
.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)
{
returnNow = true;
break;
}
// Any other key: cancel countdown, show "hold" message
logPanel.Add("[INFO] Countdown cancelled. Press [Enter] or [Esc] to return to menu.");
}
// Update footer countdown
var elapsed = DateTime.Now - startTime;
string footer;
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][/]";
}
else
{
footer = "[dim]Press [Enter] or [Esc] to return to menu.[/]";
}
ctx.UpdateTarget(BuildLayoutWithFooter(logPanel, stats, startTime, statusMsg, footer));
await Task.Delay(200, CancellationToken.None);
}
});
await countdownTask;
}
// -----------------------------------------------------------------------
// Layout builders
// -----------------------------------------------------------------------
private static IRenderable BuildLayout(
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)
{
var elapsed = DateTime.Now - startTime;
var snap = stats.CurrentSnapshot;
// Stats panel (left) — fixed width ~36 chars
var statsPanelWidth = 36;
var statsRenderable = StatsPanel.Build(snap, elapsed, statsPanelWidth - 4);
// Log panel (right) — fills remaining space
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);
// 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}"));
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[/]"));
return outerGrid;
}
private static string ExtractTitle(string statusMsg)
{
// statusMsg is markup like "[yellow]Running...[/]" — we just return a static title
return "HANA Operation";
}
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";
}
}