diff --git a/Examples/InlineSelect/Program.cs b/Examples/InlineSelect/Program.cs index 1a44d11e0e..a80daf7ae1 100644 --- a/Examples/InlineSelect/Program.cs +++ b/Examples/InlineSelect/Program.cs @@ -3,11 +3,13 @@ // Renders an OptionSelector inline in the terminal with options from the command line. // Supports horizontal or vertical orientation via --horizontal / --vertical flags. // Hot keys are auto-assigned from option text. +// Supports --timeout to auto-cancel via CancellationToken (demonstrates RunAsync). // // Usage: // dotnet run --project Examples/InlineSelect -- Apple Banana Cherry // dotnet run --project Examples/InlineSelect -- --horizontal Red Green Blue Yellow // dotnet run --project Examples/InlineSelect -- --vertical One Two Three +// dotnet run --project Examples/InlineSelect -- --timeout 10 Apple Banana Cherry using Terminal.Gui.App; using Terminal.Gui.Drawing; @@ -17,9 +19,12 @@ // Parse command-line arguments Orientation orientation = Orientation.Vertical; List options = []; +int? timeoutSeconds = null; -foreach (string arg in args) +for (int i = 0; i < args.Length; i++) { + string arg = args [i]; + if (arg is "--horizontal" or "-h") { orientation = Orientation.Horizontal; @@ -28,6 +33,20 @@ { orientation = Orientation.Vertical; } + else if (arg is "--timeout" or "-t") + { + if (i + 1 < args.Length && int.TryParse (args [i + 1], out int seconds)) + { + timeoutSeconds = seconds; + i++; // skip the next arg (the number) + } + else + { + Console.Error.WriteLine ("Error: --timeout requires a number of seconds."); + + return 1; + } + } else { options.Add (arg); @@ -36,7 +55,7 @@ if (options.Count == 0) { - Console.Error.WriteLine ("Usage: InlineSelect [--horizontal|--vertical] ..."); + Console.Error.WriteLine ("Usage: InlineSelect [--horizontal|--vertical] [--timeout ] ..."); return 1; } @@ -57,13 +76,46 @@ // Wrap in RunnableWrapper — auto-extracts Value via IValue RunnableWrapper wrapper = new (selector) { - Title = "Select an option (Enter to accept, Esc to cancel)", + Title = timeoutSeconds.HasValue + ? $"Select an option (Enter to accept, Esc to cancel, {timeoutSeconds}s timeout)" + : "Select an option (Enter to accept, Esc to cancel)", Width = Dim.Fill (), BorderStyle = LineStyle.Rounded }; -// Run inline — blocks until user accepts or cancels -app.Run (wrapper); +// Run with optional timeout via RunAsync + CancellationToken +if (timeoutSeconds.HasValue) +{ + // Use RunAsync with a CancellationToken for timeout-based cancellation + using CancellationTokenSource cts = new (TimeSpan.FromSeconds (timeoutSeconds.Value)); + + // Show terminal progress indicator counting down the timeout (OSC 9;4) + DateTime startTime = DateTime.UtcNow; + int totalMs = timeoutSeconds.Value * 1000; + + using Timer progressTimer = new ( + _ => app.Invoke ( + _ => + { + int elapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds; + int percent = Math.Min (elapsedMs * 100 / totalMs, 100); + app.Driver?.ProgressIndicator?.SetValue (percent); + }), + null, + 0, + 250); + + await app.RunAsync (wrapper, cts.Token); + + // Clear the progress indicator when done + progressTimer.Change (System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite); + app.Driver?.ProgressIndicator?.Clear (); +} +else +{ + // Run synchronously — blocks until user accepts or cancels + app.Run (wrapper); +} int? result = wrapper.Result; diff --git a/Terminal.Gui/App/ApplicationImpl.Run.cs b/Terminal.Gui/App/ApplicationImpl.Run.cs index 559caab090..3d1393f327 100644 --- a/Terminal.Gui/App/ApplicationImpl.Run.cs +++ b/Terminal.Gui/App/ApplicationImpl.Run.cs @@ -265,6 +265,95 @@ public void Invoke (Action action) return token.Result; } + /// + public Task RunAsync (IRunnable runnable, CancellationToken cancellationToken, Func? errorHandler = null) + { + ArgumentNullException.ThrowIfNull (runnable); + + if (cancellationToken.IsCancellationRequested) + { + return Task.FromResult (null); + } + + TaskCompletionSource tcs = new (TaskCreationOptions.RunContinuationsAsynchronously); + + // Register the cancellation token to request stop on the main loop via Invoke. + CancellationTokenRegistration registration = cancellationToken.Register (() => Invoke (() => RequestStop (runnable))); + + try + { + object? result = Run (runnable, errorHandler); + tcs.TrySetResult (result); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + tcs.TrySetCanceled (cancellationToken); + } + catch (Exception ex) + { + tcs.TrySetException (ex); + } + finally + { + registration.Dispose (); + } + + return tcs.Task; + } + + /// + public Task RunAsync (CancellationToken cancellationToken, Func? errorHandler = null, string? driverName = null) where TRunnable : IRunnable, new () + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromResult (this); + } + + if (!Initialized) + { + // Init() has NOT been called. Auto-initialize as per interface contract. + Init (driverName); + } + + if (Driver is null) + { + throw new InvalidOperationException (@"Driver is null after Init."); + } + + TRunnable runnable = new (); + + TaskCompletionSource tcs = new (TaskCreationOptions.RunContinuationsAsynchronously); + + // Register the cancellation token to request stop on the main loop via Invoke. + CancellationTokenRegistration registration = cancellationToken.Register (() => Invoke (() => RequestStop (runnable))); + + try + { + Run (runnable, errorHandler); + tcs.TrySetResult (this); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + tcs.TrySetCanceled (cancellationToken); + } + catch (Exception ex) + { + tcs.TrySetException (ex); + } + finally + { + registration.Dispose (); + + // We created the runnable, so dispose it if it's disposable + if (runnable is IDisposable disposable) + { + disposable.Dispose (); + } + } + + return tcs.Task; + } + private void RunLoop (IRunnable runnable, Func? errorHandler) { runnable.StopRequested = false; diff --git a/Terminal.Gui/App/IApplication.cs b/Terminal.Gui/App/IApplication.cs index 8873d0a044..16d4dc063c 100644 --- a/Terminal.Gui/App/IApplication.cs +++ b/Terminal.Gui/App/IApplication.cs @@ -219,6 +219,64 @@ public interface IApplication : IDisposable /// object? Run (IRunnable runnable, Func? errorHandler = null); + /// + /// Asynchronously runs a new Session with the provided runnable view, observing a . + /// + /// The runnable to execute. + /// + /// A cancellation token that, when cancelled, will call to cleanly stop the run + /// loop. + /// + /// Optional handler for unhandled exceptions (resumes when returns true, rethrows when null). + /// + /// A that completes when the session ends. The result is the value returned by + /// . + /// + /// + /// + /// This method wraps and registers the + /// so that cancellation triggers . + /// + /// + /// If the token is already cancelled when this method is called, it returns immediately without starting the + /// session. + /// + /// + /// Cancellation is idempotent: if both the token and an internal fire, only a single + /// shutdown occurs. + /// + /// + Task RunAsync (IRunnable runnable, CancellationToken cancellationToken, Func? errorHandler = null); + + /// + /// Asynchronously runs a new Session creating a -derived object of type + /// , observing a . + /// + /// The type of runnable to create and run. + /// + /// A cancellation token that, when cancelled, will call to cleanly stop the run + /// loop. + /// + /// Optional handler for unhandled exceptions (resumes when returns true, rethrows when null). + /// + /// The driver name. If not specified the default driver for the platform will be used. + /// + /// + /// A representing the asynchronous operation. The instance is returned + /// for fluent chaining. + /// + /// + /// + /// This method wraps and registers the + /// so that cancellation triggers . + /// + /// + /// If the token is already cancelled when this method is called, it returns immediately without starting the + /// session. + /// + /// + Task RunAsync (CancellationToken cancellationToken, Func? errorHandler = null, string? driverName = null) where TRunnable : IRunnable, new (); + /// /// Runs a new Session creating a -derived object of type /// and calling . When the session is stopped, diff --git a/Tests/UnitTestsParallelizable/Application/RunAsyncTests.cs b/Tests/UnitTestsParallelizable/Application/RunAsyncTests.cs new file mode 100644 index 0000000000..74f2e6a84c --- /dev/null +++ b/Tests/UnitTestsParallelizable/Application/RunAsyncTests.cs @@ -0,0 +1,176 @@ +#nullable enable + +// Copilot + +namespace ApplicationTests; + +[Collection ("Application Tests")] +public class RunAsyncTests +{ + [Fact] + public async Task RunAsync_TokenCancelledBefore_ReturnsImmediately () + { + // Arrange + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new (); + using CancellationTokenSource cts = new (); + cts.Cancel (); // Cancel before calling RunAsync + + // Act + object? result = await app.RunAsync (runnable, cts.Token); + + // Assert - should return null immediately without starting + Assert.Null (result); + Assert.False (runnable.IsRunning); + + runnable.Dispose (); + app.Dispose (); + } + + [Fact] + public async Task RunAsync_TokenCancelledMidRun_LoopExits () + { + // Arrange + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new (); + using CancellationTokenSource cts = new (); + var iterationCount = 0; + + void OnIteration (object? s, EventArgs a) + { + iterationCount++; + + if (iterationCount >= 2) + { + cts.Cancel (); + } + } + + app.Iteration += OnIteration; + + // Act + object? result = await app.RunAsync (runnable, cts.Token); + + app.Iteration -= OnIteration; + + // Assert - loop should have exited cleanly + Assert.False (runnable.IsRunning); + Assert.True (iterationCount >= 2); + + runnable.Dispose (); + app.Dispose (); + } + + [Fact] + public async Task RunAsync_BothRequestStopAndToken_IdempotentShutdown () + { + // Arrange + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new (); + using CancellationTokenSource cts = new (); + + void OnIteration (object? s, EventArgs a) + { + // Both cancel the token AND call RequestStop + cts.Cancel (); + app.RequestStop (runnable); + } + + app.Iteration += OnIteration; + + // Act - should not throw or deadlock + object? result = await app.RunAsync (runnable, cts.Token); + + app.Iteration -= OnIteration; + + // Assert - clean shutdown + Assert.False (runnable.IsRunning); + + runnable.Dispose (); + app.Dispose (); + } + + [Fact] + public async Task RunAsync_UnhandledException_FaultedTask () + { + // Arrange + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable runnable = new (); + using CancellationTokenSource cts = new (); + InvalidOperationException expectedException = new ("Test exception from run loop"); + + void OnIteration (object? s, EventArgs a) => throw expectedException; + + app.Iteration += OnIteration; + + // Act & Assert - the task should propagate the exception + InvalidOperationException ex = await Assert.ThrowsAsync ( + async () => await app.RunAsync (runnable, cts.Token)); + + Assert.Same (expectedException, ex); + + app.Iteration -= OnIteration; + runnable.Dispose (); + app.Dispose (); + } + + [Fact] + public async Task RunAsync_Generic_TokenCancelledBefore_ReturnsImmediately () + { + // Arrange + IApplication app = Application.Create (); + using CancellationTokenSource cts = new (); + cts.Cancel (); // Cancel before calling RunAsync + + app.StopAfterFirstIteration = true; + + // Act + IApplication result = await app.RunAsync (cts.Token, driverName: DriverRegistry.Names.ANSI); + + // Assert - should return immediately without starting + Assert.Same (app, result); + + app.Dispose (); + } + + [Fact] + public async Task RunAsync_Generic_TokenCancelledMidRun_LoopExits () + { + // Arrange + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + using CancellationTokenSource cts = new (); + var iterationCount = 0; + + void OnIteration (object? s, EventArgs a) + { + iterationCount++; + + if (iterationCount >= 2) + { + cts.Cancel (); + } + } + + app.Iteration += OnIteration; + + // Act + IApplication result = await app.RunAsync (cts.Token); + + app.Iteration -= OnIteration; + + // Assert + Assert.Same (app, result); + Assert.True (iterationCount >= 2); + + app.Dispose (); + } +}