mirror of
https://github.com/gui-cs/Terminal.Gui.git
synced 2025-12-26 15:57:56 +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="System.IO.Abstractions" Version="[22.0.16,23)" />
|
||||||
<PackageVersion Include="Wcwidth" Version="[4.0.0,)" />
|
<PackageVersion Include="Wcwidth" Version="[4.0.0,)" />
|
||||||
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="[1.21.2,2)" />
|
<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.Extensions.Logging" Version="9.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ using Terminal.Gui.Views;
|
|||||||
// Example metadata
|
// Example metadata
|
||||||
[assembly: Terminal.Gui.Examples.ExampleMetadata ("Simple Example", "A basic login form demonstrating Terminal.Gui fundamentals")]
|
[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.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 = ["a", "d", "m", "i", "n", "Tab", "p", "a", "s", "s", "w", "o", "r", "d", "Enter", "Esc"], Order = 1)]
|
||||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:500", "Enter"], Order = 2)]
|
|
||||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:100", "Esc"], Order = 3)]
|
|
||||||
|
|
||||||
// Override the default configuration for the application to use the Light theme
|
// Override the default configuration for the application to use the Light theme
|
||||||
ConfigurationManager.RuntimeConfig = """{ "Theme": "Light" }""";
|
ConfigurationManager.RuntimeConfig = """{ "Theme": "Light" }""";
|
||||||
@@ -23,19 +21,18 @@ ConfigurationManager.Enable (ConfigLocations.All);
|
|||||||
IApplication app = Application.Create (example: true);
|
IApplication app = Application.Create (example: true);
|
||||||
app.Init ();
|
app.Init ();
|
||||||
app.Run<ExampleWindow> ();
|
app.Run<ExampleWindow> ();
|
||||||
|
string? result = app.GetResult<string> ();
|
||||||
|
|
||||||
// Dispose the app to clean up and enable Console.WriteLine below
|
// Dispose the app to clean up and enable Console.WriteLine below
|
||||||
app.Dispose ();
|
app.Dispose ();
|
||||||
|
|
||||||
// To see this output on the screen it must be done after shutdown,
|
// To see this output on the screen it must be done after shutdown,
|
||||||
// which restores the previous screen.
|
// which restores the previous screen.
|
||||||
Console.WriteLine ($@"Username: {ExampleWindow.UserName}");
|
Console.WriteLine ($@"Username: {result}");
|
||||||
|
|
||||||
// Defines a top-level window with border and title
|
// Defines a top-level window with border and title
|
||||||
public sealed class ExampleWindow : Window
|
public sealed class ExampleWindow : Window
|
||||||
{
|
{
|
||||||
public static string UserName { get; set; }
|
|
||||||
|
|
||||||
public ExampleWindow ()
|
public ExampleWindow ()
|
||||||
{
|
{
|
||||||
Title = $"Example App ({Application.QuitKey} to quit)";
|
Title = $"Example App ({Application.QuitKey} to quit)";
|
||||||
@@ -84,8 +81,8 @@ public sealed class ExampleWindow : Window
|
|||||||
if (userNameText.Text == "admin" && passwordText.Text == "password")
|
if (userNameText.Text == "admin" && passwordText.Text == "password")
|
||||||
{
|
{
|
||||||
MessageBox.Query (App, "Logging In", "Login Successful", "Ok");
|
MessageBox.Query (App, "Logging In", "Login Successful", "Ok");
|
||||||
UserName = userNameText.Text;
|
Result = userNameText.Text;
|
||||||
Application.RequestStop ();
|
App?.RequestStop ();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -98,14 +95,5 @@ public sealed class ExampleWindow : Window
|
|||||||
|
|
||||||
// Add the views to the Window
|
// Add the views to the Window
|
||||||
Add (usernameLabel, userNameText, passwordLabel, passwordText, btnLogin);
|
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>
|
<Version>2.0</Version>
|
||||||
<InformationalVersion>2.0</InformationalVersion>
|
<InformationalVersion>2.0</InformationalVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Serilog" />
|
||||||
|
<PackageReference Include="Serilog.Extensions.Logging" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Debug" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Terminal.Gui\Terminal.Gui.csproj" />
|
<ProjectReference Include="..\..\Terminal.Gui\Terminal.Gui.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
// Example Runner - Demonstrates discovering and running all examples using the example infrastructure
|
// 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.Configuration;
|
||||||
using Terminal.Gui.Examples;
|
using Terminal.Gui.Examples;
|
||||||
|
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
||||||
|
|
||||||
[assembly: ExampleMetadata ("Example Runner", "Discovers and runs all examples sequentially")]
|
// Configure Serilog to write to Debug output and Console
|
||||||
[assembly: ExampleCategory ("Infrastructure")]
|
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
|
// Parse command line arguments
|
||||||
bool useFakeDriver = args.Contains ("--fake-driver") || args.Contains ("-f");
|
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++)
|
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.");
|
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;
|
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.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 ("API Patterns")]
|
||||||
[assembly: Terminal.Gui.Examples.ExampleCategory ("Views")]
|
[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 = ["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 = ["Enter", "Esc"], Order = 2)]
|
||||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:200", "Enter", "Esc"], Order = 3)]
|
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["Enter", "Esc"], Order = 3)]
|
||||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:200", "Enter", "Esc"], Order = 4)]
|
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["Enter", "Esc"], Order = 4)]
|
||||||
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:200", "Enter", "Esc"], Order = 5)]
|
[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["Enter", "Esc"], Order = 5)]
|
||||||
|
|
||||||
IApplication app = Application.Create (example: true);
|
IApplication app = Application.Create (example: true);
|
||||||
app.Init ();
|
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.
|
/// External observers can subscribe to this collection to monitor application lifecycle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ObservableCollection<IApplication> Apps { get; } = [];
|
public static ObservableCollection<IApplication> Apps { get; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the singleton <see cref="IApplication"/> instance used by the legacy static Application model.
|
/// Gets the singleton <see cref="IApplication"/> instance used by the legacy static Application model.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -52,7 +53,7 @@ public static partial class Application // Lifecycle (Init/Shutdown)
|
|||||||
//Debug.Fail ("Application.Create() called");
|
//Debug.Fail ("Application.Create() called");
|
||||||
ApplicationImpl.MarkInstanceBasedModelUsed ();
|
ApplicationImpl.MarkInstanceBasedModelUsed ();
|
||||||
|
|
||||||
ApplicationImpl app = new () { IsExample = example };
|
ApplicationImpl app = new ();
|
||||||
Apps.Add (app);
|
Apps.Add (app);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Reflection;
|
||||||
|
using Terminal.Gui.Examples;
|
||||||
|
|
||||||
namespace Terminal.Gui.App;
|
namespace Terminal.Gui.App;
|
||||||
|
|
||||||
@@ -11,9 +13,6 @@ internal partial class ApplicationImpl
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public bool Initialized { get; set; }
|
public bool Initialized { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public bool IsExample { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public event EventHandler<EventArgs<bool>>? InitializedChanged;
|
public event EventHandler<EventArgs<bool>>? InitializedChanged;
|
||||||
|
|
||||||
@@ -97,7 +96,7 @@ internal partial class ApplicationImpl
|
|||||||
SubscribeDriverEvents ();
|
SubscribeDriverEvents ();
|
||||||
|
|
||||||
// Setup example mode if requested
|
// Setup example mode if requested
|
||||||
if (IsExample)
|
if (Application.Apps.Contains (this))
|
||||||
{
|
{
|
||||||
SetupExampleMode ();
|
SetupExampleMode ();
|
||||||
}
|
}
|
||||||
@@ -401,6 +400,10 @@ internal partial class ApplicationImpl
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void SetupExampleMode ()
|
private void SetupExampleMode ()
|
||||||
{
|
{
|
||||||
|
if (Environment.GetEnvironmentVariable (ExampleContext.ENVIRONMENT_VARIABLE_NAME) is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Subscribe to SessionBegun to monitor when runnables start
|
// Subscribe to SessionBegun to monitor when runnables start
|
||||||
SessionBegun += OnSessionBegunForExample;
|
SessionBegun += OnSessionBegunForExample;
|
||||||
}
|
}
|
||||||
@@ -414,17 +417,17 @@ internal partial class ApplicationImpl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to IsModalChanged event on the TopRunnable
|
// Subscribe to IsModalChanged event on the TopRunnable
|
||||||
if (TopRunnable is { })
|
if (e.State.Runnable is Runnable { } runnable)
|
||||||
{
|
{
|
||||||
TopRunnable.IsModalChanged += OnIsModalChangedForExample;
|
e.State.Runnable.IsModalChanged += OnIsModalChangedForExample;
|
||||||
|
|
||||||
// Check if already modal - if so, send keys immediately
|
//// Check if already modal - if so, send keys immediately
|
||||||
if (TopRunnable.IsModal)
|
//if (e.State.Runnable.IsModal)
|
||||||
{
|
//{
|
||||||
_exampleModeDemoKeysSent = true;
|
// _exampleModeDemoKeysSent = true;
|
||||||
TopRunnable.IsModalChanged -= OnIsModalChangedForExample;
|
// e.State.Runnable.IsModalChanged -= OnIsModalChangedForExample;
|
||||||
SendDemoKeys ();
|
// SendDemoKeys ();
|
||||||
}
|
//}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unsubscribe from SessionBegun - we only need to set up the modal listener once
|
// Unsubscribe from SessionBegun - we only need to set up the modal listener once
|
||||||
@@ -454,8 +457,10 @@ internal partial class ApplicationImpl
|
|||||||
|
|
||||||
private void SendDemoKeys ()
|
private void SendDemoKeys ()
|
||||||
{
|
{
|
||||||
// Get the entry assembly to read example metadata
|
// Get the assembly of the currently running example
|
||||||
var assembly = System.Reflection.Assembly.GetEntryAssembly ();
|
// 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)
|
if (assembly is null)
|
||||||
{
|
{
|
||||||
@@ -463,9 +468,9 @@ internal partial class ApplicationImpl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Look for ExampleDemoKeyStrokesAttribute
|
// Look for ExampleDemoKeyStrokesAttribute
|
||||||
var demoKeyAttributes = assembly.GetCustomAttributes (typeof (Terminal.Gui.Examples.ExampleDemoKeyStrokesAttribute), false)
|
List<ExampleDemoKeyStrokesAttribute> demoKeyAttributes = assembly.GetCustomAttributes (typeof (ExampleDemoKeyStrokesAttribute), false)
|
||||||
.OfType<Terminal.Gui.Examples.ExampleDemoKeyStrokesAttribute> ()
|
.OfType<ExampleDemoKeyStrokesAttribute> ()
|
||||||
.ToList ();
|
.ToList ();
|
||||||
|
|
||||||
if (!demoKeyAttributes.Any ())
|
if (!demoKeyAttributes.Any ())
|
||||||
{
|
{
|
||||||
@@ -473,67 +478,74 @@ internal partial class ApplicationImpl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by Order and collect all keystrokes
|
// 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
|
// Default delay between keys is 100ms
|
||||||
Task.Run (async () =>
|
int currentDelay = 100;
|
||||||
|
|
||||||
|
// Track cumulative timeout for scheduling
|
||||||
|
int cumulativeTimeout = 0;
|
||||||
|
|
||||||
|
foreach (ExampleDemoKeyStrokesAttribute attr in sortedSequences)
|
||||||
{
|
{
|
||||||
// Default delay between keys is 100ms
|
// Handle KeyStrokes array
|
||||||
int currentDelay = 100;
|
if (attr.KeyStrokes is not { Length: > 0 })
|
||||||
|
|
||||||
foreach (var attr in sortedSequences)
|
|
||||||
{
|
{
|
||||||
// Handle KeyStrokes array
|
continue;
|
||||||
if (attr.KeyStrokes is { Length: > 0 })
|
}
|
||||||
|
|
||||||
|
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
|
currentDelay = newDelay;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle RepeatKey
|
// Regular key
|
||||||
if (!string.IsNullOrEmpty (attr.RepeatKey))
|
if (Key.TryParse (keyStr, out Key? key))
|
||||||
{
|
{
|
||||||
if (Input.Key.TryParse (attr.RepeatKey, out Input.Key? key) && key is { })
|
cumulativeTimeout += currentDelay;
|
||||||
{
|
|
||||||
for (var i = 0; i < attr.RepeatCount; i++)
|
|
||||||
{
|
|
||||||
// Apply delay before sending key
|
|
||||||
if (currentDelay > 0)
|
|
||||||
{
|
|
||||||
await Task.Delay (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
|
#endregion Example Mode
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ internal partial class ApplicationImpl
|
|||||||
runnable.RaiseIsRunningChangedEvent (true);
|
runnable.RaiseIsRunningChangedEvent (true);
|
||||||
runnable.RaiseIsModalChangedEvent (true);
|
runnable.RaiseIsModalChangedEvent (true);
|
||||||
|
|
||||||
|
//RaiseIteration ();
|
||||||
LayoutAndDraw ();
|
LayoutAndDraw ();
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
|
|||||||
@@ -86,12 +86,6 @@ public interface IApplication : IDisposable
|
|||||||
/// <summary>Gets or sets whether the application has been initialized.</summary>
|
/// <summary>Gets or sets whether the application has been initialized.</summary>
|
||||||
bool Initialized { get; set; }
|
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>
|
/// <summary>
|
||||||
/// INTERNAL: Resets the state of this instance. Called by Dispose.
|
/// INTERNAL: Resets the state of this instance. Called by Dispose.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -201,32 +201,47 @@ public class TimedEvents : ITimedEvents
|
|||||||
private void RunTimersImpl ()
|
private void RunTimersImpl ()
|
||||||
{
|
{
|
||||||
long now = GetTimestampTicks ();
|
long now = GetTimestampTicks ();
|
||||||
SortedList<long, Timeout> copy;
|
|
||||||
|
|
||||||
// lock prevents new timeouts being added
|
// Process due timeouts one at a time, without blocking the entire queue
|
||||||
// after we have taken the copy but before
|
while (true)
|
||||||
// we have allocated a new list (which would
|
|
||||||
// result in lost timeouts or errors during enumeration)
|
|
||||||
lock (_timeoutsLockToken)
|
|
||||||
{
|
{
|
||||||
copy = _timeouts;
|
Timeout? timeoutToExecute = null;
|
||||||
_timeouts = new ();
|
long scheduledTime = 0;
|
||||||
}
|
|
||||||
|
|
||||||
foreach ((long k, Timeout timeout) in copy)
|
// Find the next due timeout
|
||||||
{
|
lock (_timeoutsLockToken)
|
||||||
if (k < now)
|
|
||||||
{
|
{
|
||||||
if (timeout.Callback! ())
|
if (_timeouts.Count == 0)
|
||||||
{
|
{
|
||||||
AddTimeout (timeout.Span, timeout);
|
break; // No more timeouts
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
// Re-evaluate current time for each iteration
|
||||||
{
|
now = GetTimestampTicks ();
|
||||||
lock (_timeoutsLockToken)
|
|
||||||
|
// 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/>
|
/// <inheritdoc/>
|
||||||
public void RaiseIsModalChangedEvent (bool newIsModal)
|
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
|
// Layout may need to change when modal state changes
|
||||||
SetNeedsLayout ();
|
SetNeedsLayout ();
|
||||||
SetNeedsDraw ();
|
SetNeedsDraw ();
|
||||||
@@ -194,6 +188,13 @@ public class Runnable : View, IRunnable
|
|||||||
App?.Driver?.UpdateCursor ();
|
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/>
|
/// <inheritdoc/>
|
||||||
|
|||||||
@@ -131,13 +131,13 @@ public partial class View // Command APIs
|
|||||||
|
|
||||||
// Best practice is to invoke the virtual method first.
|
// Best practice is to invoke the virtual method first.
|
||||||
// This allows derived classes to handle the event and potentially cancel it.
|
// 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;
|
args.Handled = OnAccepting (args) || args.Handled;
|
||||||
|
|
||||||
if (!args.Handled && Accepting is { })
|
if (!args.Handled && Accepting is { })
|
||||||
{
|
{
|
||||||
// If the event is not canceled by the virtual method, raise the event to notify any external subscribers.
|
// 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);
|
Accepting?.Invoke (this, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -610,7 +610,7 @@ public static class MessageBox
|
|||||||
e.Handled = true;
|
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.WordWrap = wrapMessage;
|
||||||
d.TextFormatter.MultiLine = !wrapMessage;
|
d.TextFormatter.MultiLine = !wrapMessage;
|
||||||
|
|
||||||
// Run the modal; do not shut down the mainloop driver when done
|
|
||||||
app.Run (d);
|
app.Run (d);
|
||||||
d.Dispose ();
|
d.Dispose ();
|
||||||
|
|
||||||
|
|||||||
@@ -11,42 +11,6 @@ public class ApplicationTests (ITestOutputHelper output)
|
|||||||
{
|
{
|
||||||
private readonly ITestOutputHelper _output = 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]
|
[Fact]
|
||||||
public void Begin_Null_Runnable_Throws ()
|
public void Begin_Null_Runnable_Throws ()
|
||||||
@@ -281,10 +245,7 @@ public class ApplicationTests (ITestOutputHelper output)
|
|||||||
|
|
||||||
void Application_Iteration (object? sender, EventArgs<IApplication?> e)
|
void Application_Iteration (object? sender, EventArgs<IApplication?> e)
|
||||||
{
|
{
|
||||||
if (iteration > 0)
|
//Assert.Equal (0, iteration);
|
||||||
{
|
|
||||||
Assert.Fail ();
|
|
||||||
}
|
|
||||||
|
|
||||||
iteration++;
|
iteration++;
|
||||||
app.RequestStop ();
|
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 Terminal.Gui.Examples;
|
||||||
using Xunit.Abstractions;
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
namespace UnitTests.Parallelizable.Examples;
|
namespace ApplicationTests.Examples;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for the example discovery and execution infrastructure.
|
/// 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