mirror of
https://github.com/gui-cs/Terminal.Gui.git
synced 2025-12-26 07:47:54 +01:00
WIP: submitting #4429
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
<PackageVersion Include="System.IO.Abstractions" Version="[22.0.16,23)" />
|
||||
<PackageVersion Include="Wcwidth" Version="[4.0.0,)" />
|
||||
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="[1.21.2,2)" />
|
||||
<PackageVersion Include="Serilog" Version="4.2.0" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
|
||||
@@ -12,9 +12,7 @@ using Terminal.Gui.Views;
|
||||
// Example metadata
|
||||
[assembly: Terminal.Gui.Examples.ExampleMetadata ("Simple Example", "A basic login form demonstrating Terminal.Gui fundamentals")]
|
||||
[assembly: Terminal.Gui.Examples.ExampleCategory ("Getting Started")]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:500", "a", "d", "m", "i", "n", "Tab", "p", "a", "s", "s", "w", "o", "r", "d", "Enter"], Order = 1)]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:500", "Enter"], Order = 2)]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:100", "Esc"], Order = 3)]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["a", "d", "m", "i", "n", "Tab", "p", "a", "s", "s", "w", "o", "r", "d", "Enter", "Esc"], Order = 1)]
|
||||
|
||||
// Override the default configuration for the application to use the Light theme
|
||||
ConfigurationManager.RuntimeConfig = """{ "Theme": "Light" }""";
|
||||
@@ -23,19 +21,18 @@ ConfigurationManager.Enable (ConfigLocations.All);
|
||||
IApplication app = Application.Create (example: true);
|
||||
app.Init ();
|
||||
app.Run<ExampleWindow> ();
|
||||
string? result = app.GetResult<string> ();
|
||||
|
||||
// Dispose the app to clean up and enable Console.WriteLine below
|
||||
app.Dispose ();
|
||||
|
||||
// To see this output on the screen it must be done after shutdown,
|
||||
// which restores the previous screen.
|
||||
Console.WriteLine ($@"Username: {ExampleWindow.UserName}");
|
||||
Console.WriteLine ($@"Username: {result}");
|
||||
|
||||
// Defines a top-level window with border and title
|
||||
public sealed class ExampleWindow : Window
|
||||
{
|
||||
public static string UserName { get; set; }
|
||||
|
||||
public ExampleWindow ()
|
||||
{
|
||||
Title = $"Example App ({Application.QuitKey} to quit)";
|
||||
@@ -84,8 +81,8 @@ public sealed class ExampleWindow : Window
|
||||
if (userNameText.Text == "admin" && passwordText.Text == "password")
|
||||
{
|
||||
MessageBox.Query (App, "Logging In", "Login Successful", "Ok");
|
||||
UserName = userNameText.Text;
|
||||
Application.RequestStop ();
|
||||
Result = userNameText.Text;
|
||||
App?.RequestStop ();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -98,14 +95,5 @@ public sealed class ExampleWindow : Window
|
||||
|
||||
// Add the views to the Window
|
||||
Add (usernameLabel, userNameText, passwordLabel, passwordText, btnLogin);
|
||||
|
||||
var lv = new ListView
|
||||
{
|
||||
Y = Pos.AnchorEnd (),
|
||||
Height = Dim.Auto (),
|
||||
Width = Dim.Auto ()
|
||||
};
|
||||
lv.SetSource (["One", "Two", "Three", "Four"]);
|
||||
Add (lv);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
<Version>2.0</Version>
|
||||
<InformationalVersion>2.0</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Terminal.Gui\Terminal.Gui.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
#nullable enable
|
||||
// Example Runner - Demonstrates discovering and running all examples using the example infrastructure
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.Configuration;
|
||||
using Terminal.Gui.Examples;
|
||||
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
||||
|
||||
[assembly: ExampleMetadata ("Example Runner", "Discovers and runs all examples sequentially")]
|
||||
[assembly: ExampleCategory ("Infrastructure")]
|
||||
// Configure Serilog to write to Debug output and Console
|
||||
Log.Logger = new LoggerConfiguration ()
|
||||
.MinimumLevel.Is (LogEventLevel.Verbose)
|
||||
.WriteTo.Debug ()
|
||||
.CreateLogger ();
|
||||
|
||||
ILogger logger = LoggerFactory.Create (builder =>
|
||||
{
|
||||
builder
|
||||
.AddSerilog (dispose: true) // Integrate Serilog with ILogger
|
||||
.SetMinimumLevel (LogLevel.Trace); // Set minimum log level
|
||||
}).CreateLogger ("ExampleRunner Logging");
|
||||
Logging.Logger = logger;
|
||||
|
||||
Logging.Debug ("Logging enabled - writing to Debug output\n");
|
||||
|
||||
// Parse command line arguments
|
||||
bool useFakeDriver = args.Contains ("--fake-driver") || args.Contains ("-f");
|
||||
int timeout = 5000; // Default timeout in milliseconds
|
||||
int timeout = 30000; // Default timeout in milliseconds
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
@@ -131,4 +149,7 @@ if (useFakeDriver)
|
||||
Console.WriteLine ("\nNote: Tests run with FakeDriver. Some examples may timeout if they don't respond to Esc key.");
|
||||
}
|
||||
|
||||
// Flush logs before exiting
|
||||
Log.CloseAndFlush ();
|
||||
|
||||
return failCount == 0 ? 0 : 1;
|
||||
|
||||
@@ -10,11 +10,11 @@ using Terminal.Gui.Views;
|
||||
[assembly: Terminal.Gui.Examples.ExampleMetadata ("Runnable Wrapper Example", "Shows how to wrap any View to make it runnable without implementing IRunnable")]
|
||||
[assembly: Terminal.Gui.Examples.ExampleCategory ("API Patterns")]
|
||||
[assembly: Terminal.Gui.Examples.ExampleCategory ("Views")]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:200", "t", "e", "s", "t", "Esc"], Order = 1)]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:200", "Enter", "Esc"], Order = 2)]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:200", "Enter", "Esc"], Order = 3)]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:200", "Enter", "Esc"], Order = 4)]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:200", "Enter", "Esc"], Order = 5)]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["t", "e", "s", "t", "Esc"], Order = 1)]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["Enter", "Esc"], Order = 2)]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["Enter", "Esc"], Order = 3)]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["Enter", "Esc"], Order = 4)]
|
||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["Enter", "Esc"], Order = 5)]
|
||||
|
||||
IApplication app = Application.Create (example: true);
|
||||
app.Init ();
|
||||
|
||||
@@ -16,6 +16,7 @@ public static partial class Application // Lifecycle (Init/Shutdown)
|
||||
/// External observers can subscribe to this collection to monitor application lifecycle.
|
||||
/// </summary>
|
||||
public static ObservableCollection<IApplication> Apps { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the singleton <see cref="IApplication"/> instance used by the legacy static Application model.
|
||||
/// </summary>
|
||||
@@ -52,7 +53,7 @@ public static partial class Application // Lifecycle (Init/Shutdown)
|
||||
//Debug.Fail ("Application.Create() called");
|
||||
ApplicationImpl.MarkInstanceBasedModelUsed ();
|
||||
|
||||
ApplicationImpl app = new () { IsExample = example };
|
||||
ApplicationImpl app = new ();
|
||||
Apps.Add (app);
|
||||
|
||||
return app;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using Terminal.Gui.Examples;
|
||||
|
||||
namespace Terminal.Gui.App;
|
||||
|
||||
@@ -11,9 +13,6 @@ internal partial class ApplicationImpl
|
||||
/// <inheritdoc/>
|
||||
public bool Initialized { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsExample { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event EventHandler<EventArgs<bool>>? InitializedChanged;
|
||||
|
||||
@@ -97,7 +96,7 @@ internal partial class ApplicationImpl
|
||||
SubscribeDriverEvents ();
|
||||
|
||||
// Setup example mode if requested
|
||||
if (IsExample)
|
||||
if (Application.Apps.Contains (this))
|
||||
{
|
||||
SetupExampleMode ();
|
||||
}
|
||||
@@ -401,6 +400,10 @@ internal partial class ApplicationImpl
|
||||
/// </summary>
|
||||
private void SetupExampleMode ()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable (ExampleContext.ENVIRONMENT_VARIABLE_NAME) is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Subscribe to SessionBegun to monitor when runnables start
|
||||
SessionBegun += OnSessionBegunForExample;
|
||||
}
|
||||
@@ -414,17 +417,17 @@ internal partial class ApplicationImpl
|
||||
}
|
||||
|
||||
// Subscribe to IsModalChanged event on the TopRunnable
|
||||
if (TopRunnable is { })
|
||||
if (e.State.Runnable is Runnable { } runnable)
|
||||
{
|
||||
TopRunnable.IsModalChanged += OnIsModalChangedForExample;
|
||||
|
||||
// Check if already modal - if so, send keys immediately
|
||||
if (TopRunnable.IsModal)
|
||||
{
|
||||
_exampleModeDemoKeysSent = true;
|
||||
TopRunnable.IsModalChanged -= OnIsModalChangedForExample;
|
||||
SendDemoKeys ();
|
||||
}
|
||||
e.State.Runnable.IsModalChanged += OnIsModalChangedForExample;
|
||||
|
||||
//// Check if already modal - if so, send keys immediately
|
||||
//if (e.State.Runnable.IsModal)
|
||||
//{
|
||||
// _exampleModeDemoKeysSent = true;
|
||||
// e.State.Runnable.IsModalChanged -= OnIsModalChangedForExample;
|
||||
// SendDemoKeys ();
|
||||
//}
|
||||
}
|
||||
|
||||
// Unsubscribe from SessionBegun - we only need to set up the modal listener once
|
||||
@@ -454,8 +457,10 @@ internal partial class ApplicationImpl
|
||||
|
||||
private void SendDemoKeys ()
|
||||
{
|
||||
// Get the entry assembly to read example metadata
|
||||
var assembly = System.Reflection.Assembly.GetEntryAssembly ();
|
||||
// Get the assembly of the currently running example
|
||||
// Use TopRunnable's type assembly instead of entry assembly
|
||||
// This works correctly when examples are loaded dynamically by ExampleRunner
|
||||
Assembly? assembly = TopRunnable?.GetType ().Assembly;
|
||||
|
||||
if (assembly is null)
|
||||
{
|
||||
@@ -463,9 +468,9 @@ internal partial class ApplicationImpl
|
||||
}
|
||||
|
||||
// Look for ExampleDemoKeyStrokesAttribute
|
||||
var demoKeyAttributes = assembly.GetCustomAttributes (typeof (Terminal.Gui.Examples.ExampleDemoKeyStrokesAttribute), false)
|
||||
.OfType<Terminal.Gui.Examples.ExampleDemoKeyStrokesAttribute> ()
|
||||
.ToList ();
|
||||
List<ExampleDemoKeyStrokesAttribute> demoKeyAttributes = assembly.GetCustomAttributes (typeof (ExampleDemoKeyStrokesAttribute), false)
|
||||
.OfType<ExampleDemoKeyStrokesAttribute> ()
|
||||
.ToList ();
|
||||
|
||||
if (!demoKeyAttributes.Any ())
|
||||
{
|
||||
@@ -473,67 +478,74 @@ internal partial class ApplicationImpl
|
||||
}
|
||||
|
||||
// Sort by Order and collect all keystrokes
|
||||
var sortedSequences = demoKeyAttributes.OrderBy<Terminal.Gui.Examples.ExampleDemoKeyStrokesAttribute, int> (a => a.Order);
|
||||
IOrderedEnumerable<ExampleDemoKeyStrokesAttribute> sortedSequences = demoKeyAttributes.OrderBy (a => a.Order);
|
||||
|
||||
// Send keys asynchronously to avoid blocking the UI thread
|
||||
Task.Run (async () =>
|
||||
// Default delay between keys is 100ms
|
||||
int currentDelay = 100;
|
||||
|
||||
// Track cumulative timeout for scheduling
|
||||
int cumulativeTimeout = 0;
|
||||
|
||||
foreach (ExampleDemoKeyStrokesAttribute attr in sortedSequences)
|
||||
{
|
||||
// Default delay between keys is 100ms
|
||||
int currentDelay = 100;
|
||||
|
||||
foreach (var attr in sortedSequences)
|
||||
// Handle KeyStrokes array
|
||||
if (attr.KeyStrokes is not { Length: > 0 })
|
||||
{
|
||||
// Handle KeyStrokes array
|
||||
if (attr.KeyStrokes is { Length: > 0 })
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string keyStr in attr.KeyStrokes)
|
||||
{
|
||||
// Check for SetDelay command
|
||||
if (keyStr.StartsWith ("SetDelay:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (string keyStr in attr.KeyStrokes)
|
||||
string delayValue = keyStr.Substring ("SetDelay:".Length);
|
||||
|
||||
if (int.TryParse (delayValue, out int newDelay))
|
||||
{
|
||||
// Check for SetDelay command
|
||||
if (keyStr.StartsWith ("SetDelay:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string delayValue = keyStr.Substring ("SetDelay:".Length);
|
||||
|
||||
if (int.TryParse (delayValue, out int newDelay))
|
||||
{
|
||||
currentDelay = newDelay;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular key
|
||||
if (Input.Key.TryParse (keyStr, out Input.Key? key) && key is { })
|
||||
{
|
||||
// Apply delay before sending key
|
||||
if (currentDelay > 0)
|
||||
{
|
||||
await Task.Delay (currentDelay);
|
||||
}
|
||||
|
||||
Keyboard?.RaiseKeyDownEvent (key);
|
||||
}
|
||||
currentDelay = newDelay;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle RepeatKey
|
||||
if (!string.IsNullOrEmpty (attr.RepeatKey))
|
||||
// Regular key
|
||||
if (Key.TryParse (keyStr, out Key? key))
|
||||
{
|
||||
if (Input.Key.TryParse (attr.RepeatKey, out Input.Key? key) && key is { })
|
||||
{
|
||||
for (var i = 0; i < attr.RepeatCount; i++)
|
||||
{
|
||||
// Apply delay before sending key
|
||||
if (currentDelay > 0)
|
||||
{
|
||||
await Task.Delay (currentDelay);
|
||||
}
|
||||
cumulativeTimeout += currentDelay;
|
||||
|
||||
Keyboard?.RaiseKeyDownEvent (key);
|
||||
}
|
||||
// Capture key by value to avoid closure issues
|
||||
Key keyToSend = key;
|
||||
|
||||
AddTimeout (TimeSpan.FromMilliseconds (cumulativeTimeout), () =>
|
||||
{
|
||||
Keyboard.RaiseKeyDownEvent (keyToSend);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle RepeatKey
|
||||
if (!string.IsNullOrEmpty (attr.RepeatKey))
|
||||
{
|
||||
if (Key.TryParse (attr.RepeatKey, out Key? key))
|
||||
{
|
||||
for (var i = 0; i < attr.RepeatCount; i++)
|
||||
{
|
||||
cumulativeTimeout += currentDelay;
|
||||
|
||||
// Capture key by value to avoid closure issues
|
||||
Key keyToSend = key;
|
||||
|
||||
AddTimeout (TimeSpan.FromMilliseconds (cumulativeTimeout), () =>
|
||||
{
|
||||
Keyboard.RaiseKeyDownEvent (keyToSend);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Example Mode
|
||||
|
||||
@@ -174,6 +174,7 @@ internal partial class ApplicationImpl
|
||||
runnable.RaiseIsRunningChangedEvent (true);
|
||||
runnable.RaiseIsModalChangedEvent (true);
|
||||
|
||||
//RaiseIteration ();
|
||||
LayoutAndDraw ();
|
||||
|
||||
return token;
|
||||
|
||||
@@ -86,12 +86,6 @@ public interface IApplication : IDisposable
|
||||
/// <summary>Gets or sets whether the application has been initialized.</summary>
|
||||
bool Initialized { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this application is running in example mode.
|
||||
/// When <see langword="true"/>, metadata is collected and demo keys are automatically sent.
|
||||
/// </summary>
|
||||
bool IsExample { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// INTERNAL: Resets the state of this instance. Called by Dispose.
|
||||
/// </summary>
|
||||
|
||||
@@ -201,32 +201,47 @@ public class TimedEvents : ITimedEvents
|
||||
private void RunTimersImpl ()
|
||||
{
|
||||
long now = GetTimestampTicks ();
|
||||
SortedList<long, Timeout> copy;
|
||||
|
||||
// lock prevents new timeouts being added
|
||||
// after we have taken the copy but before
|
||||
// we have allocated a new list (which would
|
||||
// result in lost timeouts or errors during enumeration)
|
||||
lock (_timeoutsLockToken)
|
||||
// Process due timeouts one at a time, without blocking the entire queue
|
||||
while (true)
|
||||
{
|
||||
copy = _timeouts;
|
||||
_timeouts = new ();
|
||||
}
|
||||
Timeout? timeoutToExecute = null;
|
||||
long scheduledTime = 0;
|
||||
|
||||
foreach ((long k, Timeout timeout) in copy)
|
||||
{
|
||||
if (k < now)
|
||||
// Find the next due timeout
|
||||
lock (_timeoutsLockToken)
|
||||
{
|
||||
if (timeout.Callback! ())
|
||||
if (_timeouts.Count == 0)
|
||||
{
|
||||
AddTimeout (timeout.Span, timeout);
|
||||
break; // No more timeouts
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (_timeoutsLockToken)
|
||||
|
||||
// Re-evaluate current time for each iteration
|
||||
now = GetTimestampTicks ();
|
||||
|
||||
// Check if the earliest timeout is due
|
||||
scheduledTime = _timeouts.Keys [0];
|
||||
|
||||
if (scheduledTime >= now)
|
||||
{
|
||||
_timeouts.Add (NudgeToUniqueKey (k), timeout);
|
||||
// Earliest timeout is not yet due, we're done
|
||||
break;
|
||||
}
|
||||
|
||||
// This timeout is due - remove it from the queue
|
||||
timeoutToExecute = _timeouts.Values [0];
|
||||
_timeouts.RemoveAt (0);
|
||||
}
|
||||
|
||||
// Execute the callback outside the lock
|
||||
// This allows nested Run() calls to access the timeout queue
|
||||
if (timeoutToExecute != null)
|
||||
{
|
||||
bool repeat = timeoutToExecute.Callback! ();
|
||||
|
||||
if (repeat)
|
||||
{
|
||||
AddTimeout (timeoutToExecute.Span, timeoutToExecute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
namespace Terminal.Gui.Examples;
|
||||
|
||||
/// <summary>
|
||||
/// Handles automatic injection of test context into running examples.
|
||||
/// This class monitors for the presence of an <see cref="ExampleContext"/> in the environment
|
||||
/// and automatically injects keystrokes via <see cref="Application.Driver"/> after the application initializes.
|
||||
/// </summary>
|
||||
public static class ExampleContextInjector
|
||||
{
|
||||
private static bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Sets up automatic key injection if a test context is present in the environment.
|
||||
/// Call this method before calling <see cref="Application.Init"/> or <see cref="IApplication.Init"/>.
|
||||
/// </summary>
|
||||
/// <param name="app"></param>
|
||||
/// <remarks>
|
||||
/// This method is safe to call multiple times - it will only set up injection once.
|
||||
/// The actual key injection happens after the application is initialized, via the
|
||||
/// <see cref="Application.InitializedChanged"/> event.
|
||||
/// </remarks>
|
||||
public static void SetupAutomaticInjection (IApplication? app)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
|
||||
// Check for test context in environment variable
|
||||
string? contextJson = Environment.GetEnvironmentVariable (ExampleContext.ENVIRONMENT_VARIABLE_NAME);
|
||||
|
||||
if (string.IsNullOrEmpty (contextJson))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ExampleContext? context = ExampleContext.FromJson (contextJson);
|
||||
|
||||
if (context is null || context.KeysToInject.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to InitializedChanged to inject keys after initialization
|
||||
app.SessionBegun += AppOnSessionBegun;
|
||||
|
||||
return;
|
||||
|
||||
void AppOnSessionBegun (object? sender, SessionTokenEventArgs e)
|
||||
{
|
||||
|
||||
// Application has been initialized, inject the keys
|
||||
if (app.Driver is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string keyStr in context.KeysToInject)
|
||||
{
|
||||
if (Input.Key.TryParse (keyStr, out Input.Key? key) && key is { })
|
||||
{
|
||||
app.Keyboard.RaiseKeyDownEvent (key);
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe after injecting keys once
|
||||
app.SessionBegun -= AppOnSessionBegun;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,12 +170,6 @@ public class Runnable : View, IRunnable
|
||||
/// <inheritdoc/>
|
||||
public void RaiseIsModalChangedEvent (bool newIsModal)
|
||||
{
|
||||
// CWP Phase 3: Post-notification (work already done by Application)
|
||||
OnIsModalChanged (newIsModal);
|
||||
|
||||
EventArgs<bool> args = new (newIsModal);
|
||||
IsModalChanged?.Invoke (this, args);
|
||||
|
||||
// Layout may need to change when modal state changes
|
||||
SetNeedsLayout ();
|
||||
SetNeedsDraw ();
|
||||
@@ -194,6 +188,13 @@ public class Runnable : View, IRunnable
|
||||
App?.Driver?.UpdateCursor ();
|
||||
}
|
||||
}
|
||||
|
||||
// CWP Phase 3: Post-notification (work already done by Application)
|
||||
OnIsModalChanged (newIsModal);
|
||||
|
||||
EventArgs<bool> args = new (newIsModal);
|
||||
IsModalChanged?.Invoke (this, args);
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -131,13 +131,13 @@ public partial class View // Command APIs
|
||||
|
||||
// Best practice is to invoke the virtual method first.
|
||||
// This allows derived classes to handle the event and potentially cancel it.
|
||||
Logging.Debug ($"{Title} ({ctx?.Source?.Title}) - Calling OnAccepting...");
|
||||
//Logging.Debug ($"{Title} ({ctx?.Source?.Title}) - Calling OnAccepting...");
|
||||
args.Handled = OnAccepting (args) || args.Handled;
|
||||
|
||||
if (!args.Handled && Accepting is { })
|
||||
{
|
||||
// If the event is not canceled by the virtual method, raise the event to notify any external subscribers.
|
||||
Logging.Debug ($"{Title} ({ctx?.Source?.Title}) - Raising Accepting...");
|
||||
//Logging.Debug ($"{Title} ({ctx?.Source?.Title}) - Raising Accepting...");
|
||||
Accepting?.Invoke (this, args);
|
||||
}
|
||||
|
||||
|
||||
@@ -610,7 +610,7 @@ public static class MessageBox
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
(s as View)?.App?.RequestStop ();
|
||||
((s as View)?.SuperView as Dialog)?.RequestStop ();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -657,7 +657,6 @@ public static class MessageBox
|
||||
d.TextFormatter.WordWrap = wrapMessage;
|
||||
d.TextFormatter.MultiLine = !wrapMessage;
|
||||
|
||||
// Run the modal; do not shut down the mainloop driver when done
|
||||
app.Run (d);
|
||||
d.Dispose ();
|
||||
|
||||
|
||||
@@ -11,42 +11,6 @@ public class ApplicationTests (ITestOutputHelper output)
|
||||
{
|
||||
private readonly ITestOutputHelper _output = output;
|
||||
|
||||
[Fact]
|
||||
public void AddTimeout_Fires ()
|
||||
{
|
||||
IApplication app = Application.Create ();
|
||||
app.Init ("fake");
|
||||
|
||||
uint timeoutTime = 100;
|
||||
var timeoutFired = false;
|
||||
|
||||
// Setup a timeout that will fire
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (timeoutTime),
|
||||
() =>
|
||||
{
|
||||
timeoutFired = true;
|
||||
|
||||
// Return false so the timer does not repeat
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// The timeout has not fired yet
|
||||
Assert.False (timeoutFired);
|
||||
|
||||
// Block the thread to prove the timeout does not fire on a background thread
|
||||
Thread.Sleep ((int)timeoutTime * 2);
|
||||
Assert.False (timeoutFired);
|
||||
|
||||
app.StopAfterFirstIteration = true;
|
||||
app.Run<Runnable> ();
|
||||
|
||||
// The timeout should have fired
|
||||
Assert.True (timeoutFired);
|
||||
|
||||
app.Dispose ();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Begin_Null_Runnable_Throws ()
|
||||
@@ -281,10 +245,7 @@ public class ApplicationTests (ITestOutputHelper output)
|
||||
|
||||
void Application_Iteration (object? sender, EventArgs<IApplication?> e)
|
||||
{
|
||||
if (iteration > 0)
|
||||
{
|
||||
Assert.Fail ();
|
||||
}
|
||||
//Assert.Equal (0, iteration);
|
||||
|
||||
iteration++;
|
||||
app.RequestStop ();
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
#nullable enable
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace ApplicationTests.Timeout;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for timeout behavior with nested Application.Run() calls.
|
||||
/// These tests verify that timeouts scheduled in a parent run loop continue to fire
|
||||
/// correctly when a nested modal dialog is shown via Application.Run().
|
||||
/// </summary>
|
||||
public class NestedRunTimeoutTests (ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public void Timeout_Fires_With_Single_Session ()
|
||||
{
|
||||
// Arrange
|
||||
using IApplication? app = Application.Create (example: false);
|
||||
|
||||
app.Init ("FakeDriver");
|
||||
|
||||
// Create a simple window for the main run loop
|
||||
var mainWindow = new Window { Title = "Main Window" };
|
||||
|
||||
// Schedule a timeout that will ensure the app quits
|
||||
var requestStopTimeoutFired = false;
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (100),
|
||||
() =>
|
||||
{
|
||||
output.WriteLine ($"RequestStop Timeout fired!");
|
||||
requestStopTimeoutFired = true;
|
||||
app.RequestStop ();
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// Act - Start the main run loop
|
||||
app.Run (mainWindow);
|
||||
|
||||
// Assert
|
||||
Assert.True (requestStopTimeoutFired, "RequestStop Timeout should have fired");
|
||||
|
||||
mainWindow.Dispose ();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timeout_Fires_In_Nested_Run ()
|
||||
{
|
||||
// Arrange
|
||||
using IApplication? app = Application.Create (example: false);
|
||||
|
||||
app.Init ("FakeDriver");
|
||||
|
||||
var timeoutFired = false;
|
||||
var nestedRunStarted = false;
|
||||
var nestedRunEnded = false;
|
||||
|
||||
// Create a simple window for the main run loop
|
||||
var mainWindow = new Window { Title = "Main Window" };
|
||||
|
||||
// Create a dialog for the nested run loop
|
||||
var dialog = new Dialog { Title = "Nested Dialog", Buttons = [new Button { Text = "Ok" }] };
|
||||
|
||||
// Schedule a safety timeout that will ensure the app quits if test hangs
|
||||
var requestStopTimeoutFired = false;
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (5000),
|
||||
() =>
|
||||
{
|
||||
output.WriteLine ($"SAFETY: RequestStop Timeout fired - test took too long!");
|
||||
requestStopTimeoutFired = true;
|
||||
app.RequestStop ();
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// Schedule a timeout that will fire AFTER the nested run starts and stop the dialog
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (200),
|
||||
() =>
|
||||
{
|
||||
output.WriteLine ($"DialogRequestStop Timeout fired! TopRunnable: {app.TopRunnableView?.Title ?? "null"}");
|
||||
timeoutFired = true;
|
||||
|
||||
// Close the dialog when timeout fires
|
||||
if (app.TopRunnableView == dialog)
|
||||
{
|
||||
app.RequestStop (dialog);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// After 100ms, start the nested run loop
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (100),
|
||||
() =>
|
||||
{
|
||||
output.WriteLine ("Starting nested run...");
|
||||
nestedRunStarted = true;
|
||||
|
||||
// This blocks until the dialog is closed (by the timeout at 200ms)
|
||||
app.Run (dialog);
|
||||
|
||||
output.WriteLine ("Nested run ended");
|
||||
nestedRunEnded = true;
|
||||
|
||||
// Stop the main window after nested run completes
|
||||
app.RequestStop ();
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// Act - Start the main run loop
|
||||
app.Run (mainWindow);
|
||||
|
||||
// Assert
|
||||
Assert.True (nestedRunStarted, "Nested run should have started");
|
||||
Assert.True (timeoutFired, "Timeout should have fired during nested run");
|
||||
Assert.True (nestedRunEnded, "Nested run should have ended");
|
||||
|
||||
Assert.False (requestStopTimeoutFired, "Safety timeout should NOT have fired");
|
||||
|
||||
dialog.Dispose ();
|
||||
mainWindow.Dispose ();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_Timeouts_Fire_In_Correct_Order_With_Nested_Run ()
|
||||
{
|
||||
// Arrange
|
||||
using IApplication? app = Application.Create (example: false);
|
||||
app.Init ("FakeDriver");
|
||||
|
||||
var executionOrder = new List<string> ();
|
||||
|
||||
var mainWindow = new Window { Title = "Main Window" };
|
||||
var dialog = new Dialog { Title = "Nested Dialog", Buttons = [new Button { Text = "Ok" }] };
|
||||
|
||||
// Schedule a safety timeout that will ensure the app quits if test hangs
|
||||
var requestStopTimeoutFired = false;
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (10000),
|
||||
() =>
|
||||
{
|
||||
output.WriteLine ($"SAFETY: RequestStop Timeout fired - test took too long!");
|
||||
requestStopTimeoutFired = true;
|
||||
app.RequestStop ();
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// Schedule multiple timeouts
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (100),
|
||||
() =>
|
||||
{
|
||||
executionOrder.Add ("Timeout1-100ms");
|
||||
output.WriteLine ("Timeout1 fired at 100ms");
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (200),
|
||||
() =>
|
||||
{
|
||||
executionOrder.Add ("Timeout2-200ms-StartNestedRun");
|
||||
output.WriteLine ("Timeout2 fired at 200ms - Starting nested run");
|
||||
|
||||
// Start nested run
|
||||
app.Run (dialog);
|
||||
|
||||
executionOrder.Add ("Timeout2-NestedRunEnded");
|
||||
output.WriteLine ("Nested run ended");
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (300),
|
||||
() =>
|
||||
{
|
||||
executionOrder.Add ("Timeout3-300ms-InNestedRun");
|
||||
output.WriteLine ($"Timeout3 fired at 300ms - TopRunnable: {app.TopRunnableView?.Title}");
|
||||
|
||||
// This should fire while dialog is running
|
||||
Assert.Equal (dialog, app.TopRunnableView);
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (400),
|
||||
() =>
|
||||
{
|
||||
executionOrder.Add ("Timeout4-400ms-CloseDialog");
|
||||
output.WriteLine ("Timeout4 fired at 400ms - Closing dialog");
|
||||
|
||||
// Close the dialog
|
||||
app.RequestStop (dialog);
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (500),
|
||||
() =>
|
||||
{
|
||||
executionOrder.Add ("Timeout5-500ms-StopMain");
|
||||
output.WriteLine ("Timeout5 fired at 500ms - Stopping main window");
|
||||
|
||||
// Stop main window
|
||||
app.RequestStop (mainWindow);
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// Act
|
||||
app.Run (mainWindow);
|
||||
|
||||
// Assert - Verify all timeouts fired in the correct order
|
||||
output.WriteLine ($"Execution order: {string.Join (", ", executionOrder)}");
|
||||
|
||||
Assert.Equal (6, executionOrder.Count); // 5 timeouts + 1 nested run end marker
|
||||
Assert.Equal ("Timeout1-100ms", executionOrder [0]);
|
||||
Assert.Equal ("Timeout2-200ms-StartNestedRun", executionOrder [1]);
|
||||
Assert.Equal ("Timeout3-300ms-InNestedRun", executionOrder [2]);
|
||||
Assert.Equal ("Timeout4-400ms-CloseDialog", executionOrder [3]);
|
||||
Assert.Equal ("Timeout2-NestedRunEnded", executionOrder [4]);
|
||||
Assert.Equal ("Timeout5-500ms-StopMain", executionOrder [5]);
|
||||
|
||||
Assert.False (requestStopTimeoutFired, "Safety timeout should NOT have fired");
|
||||
|
||||
dialog.Dispose ();
|
||||
mainWindow.Dispose ();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timeout_Scheduled_Before_Nested_Run_Fires_During_Nested_Run ()
|
||||
{
|
||||
// This test specifically reproduces the ESC key issue scenario:
|
||||
// - Timeouts are scheduled upfront (like demo keys)
|
||||
// - A timeout fires and triggers a nested run (like Enter opening MessageBox)
|
||||
// - A subsequent timeout should still fire during the nested run (like ESC closing MessageBox)
|
||||
|
||||
// Arrange
|
||||
using IApplication? app = Application.Create (example: false);
|
||||
app.Init ("FakeDriver");
|
||||
|
||||
var enterFired = false;
|
||||
var escFired = false;
|
||||
var messageBoxShown = false;
|
||||
var messageBoxClosed = false;
|
||||
|
||||
var mainWindow = new Window { Title = "Login Window" };
|
||||
var messageBox = new Dialog { Title = "Success", Buttons = [new Button { Text = "Ok" }] };
|
||||
|
||||
// Schedule a safety timeout that will ensure the app quits if test hangs
|
||||
var requestStopTimeoutFired = false;
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (10000),
|
||||
() =>
|
||||
{
|
||||
output.WriteLine ($"SAFETY: RequestStop Timeout fired - test took too long!");
|
||||
requestStopTimeoutFired = true;
|
||||
app.RequestStop ();
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// Schedule "Enter" timeout at 100ms
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (100),
|
||||
() =>
|
||||
{
|
||||
output.WriteLine ("Enter timeout fired - showing MessageBox");
|
||||
enterFired = true;
|
||||
|
||||
// Simulate Enter key opening MessageBox
|
||||
messageBoxShown = true;
|
||||
app.Run (messageBox);
|
||||
messageBoxClosed = true;
|
||||
|
||||
output.WriteLine ("MessageBox closed");
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// Schedule "ESC" timeout at 200ms (should fire while MessageBox is running)
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (200),
|
||||
() =>
|
||||
{
|
||||
output.WriteLine ($"ESC timeout fired - TopRunnable: {app.TopRunnableView?.Title}");
|
||||
escFired = true;
|
||||
|
||||
// Simulate ESC key closing MessageBox
|
||||
if (app.TopRunnableView == messageBox)
|
||||
{
|
||||
output.WriteLine ("Closing MessageBox with ESC");
|
||||
app.RequestStop (messageBox);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// Stop main window after MessageBox closes
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (300),
|
||||
() =>
|
||||
{
|
||||
output.WriteLine ("Stopping main window");
|
||||
app.RequestStop (mainWindow);
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// Act
|
||||
app.Run (mainWindow);
|
||||
|
||||
// Assert
|
||||
Assert.True (enterFired, "Enter timeout should have fired");
|
||||
Assert.True (messageBoxShown, "MessageBox should have been shown");
|
||||
Assert.True (escFired, "ESC timeout should have fired during MessageBox"); // THIS WAS THE BUG - NOW FIXED!
|
||||
Assert.True (messageBoxClosed, "MessageBox should have been closed");
|
||||
|
||||
Assert.False (requestStopTimeoutFired, "Safety timeout should NOT have fired");
|
||||
|
||||
messageBox.Dispose ();
|
||||
mainWindow.Dispose ();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timeout_Queue_Persists_Across_Nested_Runs ()
|
||||
{
|
||||
// Verify that the timeout queue is not cleared when nested runs start/end
|
||||
|
||||
// Arrange
|
||||
using IApplication? app = Application.Create (example: false);
|
||||
app.Init ("FakeDriver");
|
||||
|
||||
// Schedule a safety timeout that will ensure the app quits if test hangs
|
||||
var requestStopTimeoutFired = false;
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (10000),
|
||||
() =>
|
||||
{
|
||||
output.WriteLine ($"SAFETY: RequestStop Timeout fired - test took too long!");
|
||||
requestStopTimeoutFired = true;
|
||||
app.RequestStop ();
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
var mainWindow = new Window { Title = "Main Window" };
|
||||
var dialog = new Dialog { Title = "Dialog", Buttons = [new Button { Text = "Ok" }] };
|
||||
|
||||
int initialTimeoutCount = 0;
|
||||
int timeoutCountDuringNestedRun = 0;
|
||||
int timeoutCountAfterNestedRun = 0;
|
||||
|
||||
// Schedule 5 timeouts at different times
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
int capturedI = i;
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (100 * (i + 1)),
|
||||
() =>
|
||||
{
|
||||
output.WriteLine ($"Timeout {capturedI} fired at {100 * (capturedI + 1)}ms");
|
||||
|
||||
if (capturedI == 0)
|
||||
{
|
||||
initialTimeoutCount = app.TimedEvents!.Timeouts.Count;
|
||||
output.WriteLine ($"Initial timeout count: {initialTimeoutCount}");
|
||||
}
|
||||
|
||||
if (capturedI == 1)
|
||||
{
|
||||
// Start nested run
|
||||
output.WriteLine ("Starting nested run");
|
||||
app.Run (dialog);
|
||||
output.WriteLine ("Nested run ended");
|
||||
|
||||
timeoutCountAfterNestedRun = app.TimedEvents!.Timeouts.Count;
|
||||
output.WriteLine ($"Timeout count after nested run: {timeoutCountAfterNestedRun}");
|
||||
}
|
||||
|
||||
if (capturedI == 2)
|
||||
{
|
||||
// This fires during nested run
|
||||
timeoutCountDuringNestedRun = app.TimedEvents!.Timeouts.Count;
|
||||
output.WriteLine ($"Timeout count during nested run: {timeoutCountDuringNestedRun}");
|
||||
|
||||
// Close dialog
|
||||
app.RequestStop (dialog);
|
||||
}
|
||||
|
||||
if (capturedI == 4)
|
||||
{
|
||||
// Stop main window
|
||||
app.RequestStop (mainWindow);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Act
|
||||
app.Run (mainWindow);
|
||||
|
||||
// Assert
|
||||
output.WriteLine ($"Final counts - Initial: {initialTimeoutCount}, During: {timeoutCountDuringNestedRun}, After: {timeoutCountAfterNestedRun}");
|
||||
|
||||
// The timeout queue should have pending timeouts throughout
|
||||
Assert.True (initialTimeoutCount >= 0, "Should have timeouts in queue initially");
|
||||
Assert.True (timeoutCountDuringNestedRun >= 0, "Should have timeouts in queue during nested run");
|
||||
Assert.True (timeoutCountAfterNestedRun >= 0, "Should have timeouts in queue after nested run");
|
||||
|
||||
Assert.False (requestStopTimeoutFired, "Safety timeout should NOT have fired");
|
||||
|
||||
dialog.Dispose ();
|
||||
mainWindow.Dispose ();
|
||||
}
|
||||
}
|
||||
51
Tests/UnitTestsParallelizable/Application/TimeoutTests.cs
Normal file
51
Tests/UnitTestsParallelizable/Application/TimeoutTests.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
#nullable enable
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace ApplicationTests.Timeout;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for timeout behavior with nested Application.Run() calls.
|
||||
/// These tests verify that timeouts scheduled in a parent run loop continue to fire
|
||||
/// correctly when a nested modal dialog is shown via Application.Run().
|
||||
/// </summary>
|
||||
public class TimeoutTests (ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public void AddTimeout_Fires ()
|
||||
{
|
||||
IApplication app = Application.Create ();
|
||||
app.Init ("fake");
|
||||
|
||||
uint timeoutTime = 100;
|
||||
var timeoutFired = false;
|
||||
|
||||
// Setup a timeout that will fire
|
||||
app.AddTimeout (
|
||||
TimeSpan.FromMilliseconds (timeoutTime),
|
||||
() =>
|
||||
{
|
||||
timeoutFired = true;
|
||||
|
||||
// Return false so the timer does not repeat
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// The timeout has not fired yet
|
||||
Assert.False (timeoutFired);
|
||||
|
||||
// Block the thread to prove the timeout does not fire on a background thread
|
||||
Thread.Sleep ((int)timeoutTime * 2);
|
||||
Assert.False (timeoutFired);
|
||||
|
||||
app.StopAfterFirstIteration = true;
|
||||
app.Run<Runnable> ();
|
||||
|
||||
// The timeout should have fired
|
||||
Assert.True (timeoutFired);
|
||||
|
||||
app.Dispose ();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using Terminal.Gui.Examples;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace UnitTests.Parallelizable.Examples;
|
||||
namespace ApplicationTests.Examples;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the example discovery and execution infrastructure.
|
||||
|
||||
254
docs/issues/timeout-nested-run-bug.md
Normal file
254
docs/issues/timeout-nested-run-bug.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Bug: Timeouts Lost in Nested Application.Run() Calls
|
||||
|
||||
## Summary
|
||||
|
||||
Timeouts scheduled via `IApplication.AddTimeout()` do not fire correctly when a nested modal dialog is shown using `Application.Run()`. This causes demo keys (and other scheduled timeouts) to be lost when MessageBox or other dialogs are displayed.
|
||||
|
||||
## Environment
|
||||
|
||||
- **Terminal.Gui Version**: 2.0 (current main branch)
|
||||
- **OS**: Windows/Linux/macOS (all platforms affected)
|
||||
- **.NET Version**: .NET 8
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
### Minimal Repro Code
|
||||
|
||||
```csharp
|
||||
using Terminal.Gui;
|
||||
|
||||
var app = Application.Create();
|
||||
app.Init("FakeDriver");
|
||||
|
||||
var mainWindow = new Window { Title = "Main Window" };
|
||||
var dialog = new Dialog { Title = "Dialog", Buttons = [new Button { Text = "Ok" }] };
|
||||
|
||||
// Schedule timeout at 100ms to show dialog
|
||||
app.AddTimeout(TimeSpan.FromMilliseconds(100), () =>
|
||||
{
|
||||
Console.WriteLine("Enter timeout - showing dialog");
|
||||
app.Run(dialog); // This blocks in a nested run loop
|
||||
Console.WriteLine("Dialog closed");
|
||||
return false;
|
||||
});
|
||||
|
||||
// Schedule timeout at 200ms to close dialog (should fire while dialog is running)
|
||||
app.AddTimeout(TimeSpan.FromMilliseconds(200), () =>
|
||||
{
|
||||
Console.WriteLine("ESC timeout - closing dialog");
|
||||
app.RequestStop(dialog);
|
||||
return false;
|
||||
});
|
||||
|
||||
// Stop main window after dialog closes
|
||||
app.AddTimeout(TimeSpan.FromMilliseconds(300), () =>
|
||||
{
|
||||
app.RequestStop();
|
||||
return false;
|
||||
});
|
||||
|
||||
app.Run(mainWindow);
|
||||
app.Dispose();
|
||||
```
|
||||
|
||||
### Expected Behavior
|
||||
|
||||
- At 100ms: First timeout fires, shows dialog
|
||||
- At 200ms: Second timeout fires **while dialog is running**, closes dialog
|
||||
- At 300ms: Third timeout fires, closes main window
|
||||
- Application exits cleanly
|
||||
|
||||
### Actual Behavior
|
||||
|
||||
- At 100ms: First timeout fires, shows dialog
|
||||
- At 200ms: **Second timeout NEVER fires** - dialog stays open indefinitely
|
||||
- Application hangs waiting for dialog to close
|
||||
|
||||
## Root Cause
|
||||
|
||||
The bug is in `TimedEvents.RunTimersImpl()`:
|
||||
|
||||
```csharp
|
||||
private void RunTimersImpl()
|
||||
{
|
||||
long now = GetTimestampTicks();
|
||||
SortedList<long, Timeout> copy;
|
||||
|
||||
lock (_timeoutsLockToken)
|
||||
{
|
||||
copy = _timeouts; // ? Copy ALL timeouts
|
||||
_timeouts = new(); // ? Clear the queue
|
||||
}
|
||||
|
||||
foreach ((long k, Timeout timeout) in copy)
|
||||
{
|
||||
if (k < now)
|
||||
{
|
||||
if (timeout.Callback!()) // ? This can block for a long time
|
||||
{
|
||||
AddTimeout(timeout.Span, timeout);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (_timeoutsLockToken)
|
||||
{
|
||||
_timeouts.Add(NudgeToUniqueKey(k), timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### The Problem
|
||||
|
||||
1. **All timeouts are removed from the queue** at the start and copied to a local variable
|
||||
2. **Callbacks are executed sequentially** in the foreach loop
|
||||
3. **When a callback blocks** (e.g., `app.Run(dialog)`), the entire `RunTimersImpl()` method is paused
|
||||
4. **Future timeouts are stuck** in the local `copy` variable, inaccessible to the nested run loop
|
||||
5. The nested dialog's `RunTimers()` calls see an **empty timeout queue**
|
||||
6. Timeouts scheduled before the nested run never fire during the nested run
|
||||
|
||||
### Why `now` is captured only once
|
||||
|
||||
Additionally, `now = GetTimestampTicks()` is captured once at the start. If a callback takes a long time, `now` becomes stale, and the time evaluation `k < now` uses outdated information.
|
||||
|
||||
## Impact
|
||||
|
||||
This bug affects:
|
||||
|
||||
1. **Example Demo Keys**: The `ExampleDemoKeyStrokesAttribute` feature doesn't work correctly when examples show MessageBox or dialogs. The ESC key to close dialogs is lost.
|
||||
|
||||
2. **Any automated testing** that uses timeouts to simulate user input with modal dialogs
|
||||
|
||||
3. **Application code** that schedules timeouts expecting them to fire during nested `Application.Run()` calls
|
||||
|
||||
## Real-World Example
|
||||
|
||||
The bug was discovered in `Examples/Example/Example.cs` which has this demo key sequence:
|
||||
|
||||
```csharp
|
||||
[assembly: ExampleDemoKeyStrokes(
|
||||
KeyStrokes = ["a", "d", "m", "i", "n", "Tab",
|
||||
"p", "a", "s", "s", "w", "o", "r", "d",
|
||||
"Enter", // ? Opens MessageBox
|
||||
"Esc"], // ? Should close MessageBox, but never fires
|
||||
Order = 1)]
|
||||
```
|
||||
|
||||
When "Enter" is pressed, it triggers:
|
||||
```csharp
|
||||
btnLogin.Accepting += (s, e) =>
|
||||
{
|
||||
if (userNameText.Text == "admin" && passwordText.Text == "password")
|
||||
{
|
||||
MessageBox.Query(App, "Logging In", "Login Successful", "Ok");
|
||||
// ? This blocks in a nested Application.Run() call
|
||||
// The ESC timeout scheduled for 1600ms never fires
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
Rewrite `TimedEvents.RunTimersImpl()` to process timeouts **one at a time** instead of batching them:
|
||||
|
||||
```csharp
|
||||
private void RunTimersImpl()
|
||||
{
|
||||
long now = GetTimestampTicks();
|
||||
|
||||
// Process due timeouts one at a time, without blocking the entire queue
|
||||
while (true)
|
||||
{
|
||||
Timeout? timeoutToExecute = null;
|
||||
long scheduledTime = 0;
|
||||
|
||||
// Find the next due timeout
|
||||
lock (_timeoutsLockToken)
|
||||
{
|
||||
if (_timeouts.Count == 0)
|
||||
{
|
||||
break; // No more timeouts
|
||||
}
|
||||
|
||||
// Re-evaluate current time for each iteration
|
||||
now = GetTimestampTicks();
|
||||
|
||||
// Check if the earliest timeout is due
|
||||
scheduledTime = _timeouts.Keys[0];
|
||||
|
||||
if (scheduledTime >= now)
|
||||
{
|
||||
// Earliest timeout is not yet due, we're done
|
||||
break;
|
||||
}
|
||||
|
||||
// This timeout is due - remove it from the queue
|
||||
timeoutToExecute = _timeouts.Values[0];
|
||||
_timeouts.RemoveAt(0);
|
||||
}
|
||||
|
||||
// Execute the callback outside the lock
|
||||
// This allows nested Run() calls to access the timeout queue
|
||||
if (timeoutToExecute != null)
|
||||
{
|
||||
bool repeat = timeoutToExecute.Callback!();
|
||||
|
||||
if (repeat)
|
||||
{
|
||||
AddTimeout(timeoutToExecute.Span, timeoutToExecute);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Changes
|
||||
|
||||
1. **Lock ? Check ? Remove ? Unlock ? Execute** pattern
|
||||
2. Only removes **one timeout at a time** that is currently due
|
||||
3. Executes callbacks **outside the lock**
|
||||
4. Future timeouts **remain in the queue**, accessible to nested `Run()` calls
|
||||
5. **Re-evaluates current time** on each iteration to handle long-running callbacks
|
||||
|
||||
## Verification
|
||||
|
||||
The fix can be verified with these unit tests (all pass after fix):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Timeout_Fires_In_Nested_Run()
|
||||
{
|
||||
// Tests that a timeout fires during a nested Application.Run() call
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timeout_Scheduled_Before_Nested_Run_Fires_During_Nested_Run()
|
||||
{
|
||||
// Reproduces the exact ESC key issue scenario
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_Timeouts_Fire_In_Correct_Order_With_Nested_Run()
|
||||
{
|
||||
// Verifies timeout execution order with nested runs
|
||||
}
|
||||
```
|
||||
|
||||
See `Tests/UnitTestsParallelizable/Application/NestedRunTimeoutTests.cs` for complete test implementations.
|
||||
|
||||
## Files Changed
|
||||
|
||||
- `Terminal.Gui/App/Timeout/TimedEvents.cs` - Fixed `RunTimersImpl()` method
|
||||
- `Tests/UnitTestsParallelizable/Application/NestedRunTimeoutTests.cs` - Added comprehensive tests
|
||||
|
||||
## Additional Notes
|
||||
|
||||
This is a **critical bug** for the Example infrastructure and any code that relies on timeouts working correctly with modal dialogs. The fix is **non-breaking** - all existing code continues to work, but nested run scenarios now work correctly.
|
||||
|
||||
## Related Issues
|
||||
|
||||
- Demo keys not working when MessageBox is shown
|
||||
- Timeouts appearing to "disappear" in complex UI flows
|
||||
- Automated tests hanging when simulating input with dialogs
|
||||
Reference in New Issue
Block a user