Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions Examples/InlineSelect/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <seconds> 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;
Expand All @@ -17,9 +19,12 @@
// Parse command-line arguments
Orientation orientation = Orientation.Vertical;
List<string> 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;
Expand All @@ -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);
Expand All @@ -36,7 +55,7 @@

if (options.Count == 0)
{
Console.Error.WriteLine ("Usage: InlineSelect [--horizontal|--vertical] <option1> <option2> ...");
Console.Error.WriteLine ("Usage: InlineSelect [--horizontal|--vertical] [--timeout <seconds>] <option1> <option2> ...");

return 1;
}
Expand All @@ -57,13 +76,46 @@
// Wrap in RunnableWrapper — auto-extracts Value via IValue<int?>
RunnableWrapper<OptionSelector, int?> 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;

Expand Down
89 changes: 89 additions & 0 deletions Terminal.Gui/App/ApplicationImpl.Run.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,95 @@ public void Invoke (Action action)
return token.Result;
}

/// <inheritdoc/>
public Task<object?> RunAsync (IRunnable runnable, CancellationToken cancellationToken, Func<Exception, bool>? errorHandler = null)
{
ArgumentNullException.ThrowIfNull (runnable);

if (cancellationToken.IsCancellationRequested)
{
return Task.FromResult<object?> (null);
}

TaskCompletionSource<object?> 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;
}

/// <inheritdoc/>
public Task<IApplication> RunAsync<TRunnable> (CancellationToken cancellationToken, Func<Exception, bool>? errorHandler = null, string? driverName = null) where TRunnable : IRunnable, new ()
{
if (cancellationToken.IsCancellationRequested)
{
return Task.FromResult<IApplication> (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<IApplication> 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<Exception, bool>? errorHandler)
{
runnable.StopRequested = false;
Expand Down
58 changes: 58 additions & 0 deletions Terminal.Gui/App/IApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,64 @@ public interface IApplication : IDisposable
/// </remarks>
object? Run (IRunnable runnable, Func<Exception, bool>? errorHandler = null);

/// <summary>
/// Asynchronously runs a new Session with the provided runnable view, observing a <see cref="CancellationToken"/>.
/// </summary>
/// <param name="runnable">The runnable to execute.</param>
/// <param name="cancellationToken">
/// A cancellation token that, when cancelled, will call <see cref="RequestStop(IRunnable)"/> to cleanly stop the run
/// loop.
/// </param>
/// <param name="errorHandler">Optional handler for unhandled exceptions (resumes when returns true, rethrows when null).</param>
/// <returns>
/// A <see cref="Task{Object}"/> that completes when the session ends. The result is the value returned by
/// <see cref="Run(IRunnable, Func{Exception, bool})"/>.
/// </returns>
/// <remarks>
/// <para>
/// This method wraps <see cref="Run(IRunnable, Func{Exception, bool})"/> and registers the
/// <paramref name="cancellationToken"/> so that cancellation triggers <see cref="RequestStop(IRunnable)"/>.
/// </para>
/// <para>
/// If the token is already cancelled when this method is called, it returns immediately without starting the
/// session.
/// </para>
/// <para>
/// Cancellation is idempotent: if both the token and an internal <see cref="RequestStop()"/> fire, only a single
/// shutdown occurs.
/// </para>
/// </remarks>
Task<object?> RunAsync (IRunnable runnable, CancellationToken cancellationToken, Func<Exception, bool>? errorHandler = null);

/// <summary>
/// Asynchronously runs a new Session creating a <see cref="IRunnable"/>-derived object of type
/// <typeparamref name="TRunnable"/>, observing a <see cref="CancellationToken"/>.
/// </summary>
/// <typeparam name="TRunnable">The type of runnable to create and run.</typeparam>
/// <param name="cancellationToken">
/// A cancellation token that, when cancelled, will call <see cref="RequestStop(IRunnable)"/> to cleanly stop the run
/// loop.
/// </param>
/// <param name="errorHandler">Optional handler for unhandled exceptions (resumes when returns true, rethrows when null).</param>
/// <param name="driverName">
/// The driver name. If not specified the default driver for the platform will be used.
/// </param>
/// <returns>
/// A <see cref="Task"/> representing the asynchronous operation. The <see cref="IApplication"/> instance is returned
/// for fluent chaining.
/// </returns>
/// <remarks>
/// <para>
/// This method wraps <see cref="Run{TRunnable}(Func{Exception, bool}, string)"/> and registers the
/// <paramref name="cancellationToken"/> so that cancellation triggers <see cref="RequestStop()"/>.
/// </para>
/// <para>
/// If the token is already cancelled when this method is called, it returns immediately without starting the
/// session.
/// </para>
/// </remarks>
Task<IApplication> RunAsync<TRunnable> (CancellationToken cancellationToken, Func<Exception, bool>? errorHandler = null, string? driverName = null) where TRunnable : IRunnable, new ();

/// <summary>
/// Runs a new Session creating a <see cref="IRunnable"/>-derived object of type <typeparamref name="TRunnable"/>
/// and calling <see cref="Run(IRunnable, Func{Exception, bool})"/>. When the session is stopped,
Expand Down
Loading
Loading