diff --git a/Directory.Packages.props b/Directory.Packages.props index 2fdb4633e7..efbccef488 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,7 +20,7 @@ - + diff --git a/Examples/Example/Example.cs b/Examples/Example/Example.cs index 9d3fd863f0..238778271a 100644 --- a/Examples/Example/Example.cs +++ b/Examples/Example/Example.cs @@ -1,4 +1,5 @@ -// A simple Terminal.Gui example in C# - using C# 9.0 Top-level statements +#nullable enable +// A simple Terminal.Gui example in C# - using C# 9.0 Top-level statements // This is a simple example application. For the full range of functionality // see the UICatalog project @@ -8,26 +9,30 @@ using Terminal.Gui.ViewBase; 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 = ["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" }"""; ConfigurationManager.Enable (ConfigLocations.All); -IApplication app = Application.Create (); - +IApplication app = Application.Create (example: true); +app.Init (); app.Run (); +string? result = app.GetResult (); // 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)"; @@ -76,8 +81,8 @@ public ExampleWindow () 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 { @@ -90,14 +95,5 @@ public ExampleWindow () // 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); } } diff --git a/Examples/ExampleRunner/ExampleRunner.csproj b/Examples/ExampleRunner/ExampleRunner.csproj new file mode 100644 index 0000000000..229966ac87 --- /dev/null +++ b/Examples/ExampleRunner/ExampleRunner.csproj @@ -0,0 +1,21 @@ + + + Exe + + + + 2.0 + 2.0 + 2.0 + 2.0 + + + + + + + + + + + diff --git a/Examples/ExampleRunner/Program.cs b/Examples/ExampleRunner/Program.cs new file mode 100644 index 0000000000..895c7ba60e --- /dev/null +++ b/Examples/ExampleRunner/Program.cs @@ -0,0 +1,155 @@ +#nullable enable +// Example Runner - Demonstrates discovering and running all examples using the example infrastructure + +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; + +// 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 = 30000; // Default timeout in milliseconds + +for (var i = 0; i < args.Length; i++) +{ + if ((args [i] == "--timeout" || args [i] == "-t") && i + 1 < args.Length) + { + if (int.TryParse (args [i + 1], out int parsedTimeout)) + { + timeout = parsedTimeout; + } + } +} + +// Configure ForceDriver via ConfigurationManager if requested +if (useFakeDriver) +{ + Console.WriteLine ("Using FakeDriver (forced via ConfigurationManager)\n"); + ConfigurationManager.RuntimeConfig = """{ "ForceDriver": "FakeDriver" }"""; + ConfigurationManager.Enable (ConfigLocations.All); +} + +// Discover examples from the Examples directory +string? assemblyDir = Path.GetDirectoryName (System.Reflection.Assembly.GetExecutingAssembly ().Location); + +if (assemblyDir is null) +{ + Console.WriteLine ("Error: Could not determine assembly directory"); + + return 1; +} + +// Go up to find the Examples directory - from bin/Debug/net8.0 to Examples +string examplesDir = Path.GetFullPath (Path.Combine (assemblyDir, "..", "..", "..", "..")); + +if (!Directory.Exists (examplesDir)) +{ + Console.WriteLine ($"Error: Examples directory not found: {examplesDir}"); + + return 1; +} + +Console.WriteLine ($"Searching for examples in: {examplesDir}\n"); + +// Discover all examples - look specifically in each example's bin directory +List examples = []; +HashSet seen = []; + +foreach (string dir in Directory.GetDirectories (examplesDir)) +{ + string binDir = Path.Combine (dir, "bin", "Debug", "net8.0"); + + if (!Directory.Exists (binDir)) + { + continue; + } + + foreach (ExampleInfo example in ExampleDiscovery.DiscoverFromDirectory (binDir, "*.dll", SearchOption.TopDirectoryOnly)) + { + // Don't include this runner in the list and avoid duplicates + if (example.Name != "Example Runner" && seen.Add (example.Name)) + { + examples.Add (example); + } + } +} + +Console.WriteLine ($"Discovered {examples.Count} examples\n"); + +// Run all examples sequentially +var successCount = 0; +var failCount = 0; + +foreach (ExampleInfo example in examples) +{ + Console.Write ($"Running: {example.Name,-40} "); + + // Create context for running the example + // Note: When running with example mode, the demo keys from attributes will be used + // We don't need to inject additional keys via the context + ExampleContext context = new () + { + DriverName = useFakeDriver ? "FakeDriver" : null, + KeysToInject = [], // Empty - let example mode handle keys from attributes + TimeoutMs = timeout, + Mode = ExecutionMode.InProcess + }; + + try + { + ExampleResult result = ExampleRunner.Run (example, context); + + if (result.Success) + { + Console.WriteLine ($"✓ Success"); + successCount++; + } + else if (result.TimedOut) + { + Console.WriteLine ($"✗ Timeout"); + failCount++; + } + else + { + Console.WriteLine ($"✗ Failed: {result.ErrorMessage ?? "Unknown"}"); + failCount++; + } + } + catch (Exception ex) + { + Console.WriteLine ($"✗ Exception: {ex.Message}"); + failCount++; + } +} + +Console.WriteLine ($"\n=== Summary: {successCount} passed, {failCount} failed ==="); + +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; diff --git a/Examples/FluentExample/Program.cs b/Examples/FluentExample/Program.cs index 026a981345..85e086580d 100644 --- a/Examples/FluentExample/Program.cs +++ b/Examples/FluentExample/Program.cs @@ -1,3 +1,4 @@ +#nullable enable // Fluent API example demonstrating IRunnable with automatic disposal and result extraction using Terminal.Gui.App; @@ -5,7 +6,14 @@ using Terminal.Gui.ViewBase; using Terminal.Gui.Views; -IApplication? app = Application.Create () +// Example metadata +[assembly: Terminal.Gui.Examples.ExampleMetadata ("Fluent API Example", "Demonstrates the fluent IApplication API with IRunnable pattern")] +[assembly: Terminal.Gui.Examples.ExampleCategory ("API Patterns")] +[assembly: Terminal.Gui.Examples.ExampleCategory ("Controls")] +[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["CursorDown", "CursorDown", "CursorRight", "Enter"], Order = 1)] +[assembly: Terminal.Gui.Examples.ExampleDemoKeyStrokes (KeyStrokes = ["SetDelay:100", "Esc"], Order = 2)] + +IApplication? app = Application.Create (example: true) .Init () .Run (); diff --git a/Examples/RunnableWrapperExample/Program.cs b/Examples/RunnableWrapperExample/Program.cs index 1eb5e9e119..db1d9b2d92 100644 --- a/Examples/RunnableWrapperExample/Program.cs +++ b/Examples/RunnableWrapperExample/Program.cs @@ -1,3 +1,4 @@ +#nullable enable // Example demonstrating how to make ANY View runnable without implementing IRunnable using Terminal.Gui.App; @@ -5,7 +6,17 @@ using Terminal.Gui.ViewBase; using Terminal.Gui.Views; -IApplication app = Application.Create (); +// Example metadata +[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 = ["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 (); // Example 1: Use extension method with result extraction diff --git a/Terminal.Gui/App/Application.Lifecycle.cs b/Terminal.Gui/App/Application.Lifecycle.cs index 9fbc9fba15..dd028b147e 100644 --- a/Terminal.Gui/App/Application.Lifecycle.cs +++ b/Terminal.Gui/App/Application.Lifecycle.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -10,6 +11,12 @@ namespace Terminal.Gui.App; public static partial class Application // Lifecycle (Init/Shutdown) { + /// + /// Gets the observable collection of all application instances. + /// External observers can subscribe to this collection to monitor application lifecycle. + /// + public static ObservableCollection Apps { get; } = []; + /// /// Gets the singleton instance used by the legacy static Application model. /// @@ -29,6 +36,10 @@ public static partial class Application // Lifecycle (Init/Shutdown) /// /// Creates a new instance. /// + /// + /// If , the application will run in example mode where metadata is collected + /// and demo keys are automatically sent when the first TopRunnable is modal. + /// /// /// The recommended pattern is for developers to call Application.Create() and then use the returned /// instance for all subsequent application operations. @@ -37,12 +48,15 @@ public static partial class Application // Lifecycle (Init/Shutdown) /// /// Thrown if the legacy static Application model has already been used in this process. /// - public static IApplication Create () + public static IApplication Create (bool example = false) { //Debug.Fail ("Application.Create() called"); ApplicationImpl.MarkInstanceBasedModelUsed (); - return new ApplicationImpl (); + ApplicationImpl app = new (); + Apps.Add (app); + + return app; } /// diff --git a/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs b/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs index acdd2a0cf1..831b9dca0d 100644 --- a/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs +++ b/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Terminal.Gui.Examples; namespace Terminal.Gui.App; @@ -93,6 +95,12 @@ public IApplication Init (string? driverName = null) RaiseInitializedChanged (this, new (true)); SubscribeDriverEvents (); + // Setup example mode if requested + if (Application.Apps.Contains (this)) + { + SetupExampleMode (); + } + SynchronizationContext.SetSynchronizationContext (new ()); MainThreadId = Thread.CurrentThread.ManagedThreadId; @@ -381,4 +389,164 @@ private void UnsubscribeApplicationEvents () Application.Force16ColorsChanged -= OnForce16ColorsChanged; Application.ForceDriverChanged -= OnForceDriverChanged; } + + #region Example Mode + + private bool _exampleModeDemoKeysSent; + + /// + /// Sets up example mode functionality - collecting metadata and sending demo keys + /// when the first TopRunnable becomes modal. + /// + private void SetupExampleMode () + { + if (Environment.GetEnvironmentVariable (ExampleContext.ENVIRONMENT_VARIABLE_NAME) is null) + { + return; + } + // Subscribe to SessionBegun to monitor when runnables start + SessionBegun += OnSessionBegunForExample; + } + + private void OnSessionBegunForExample (object? sender, SessionTokenEventArgs e) + { + // Only send demo keys once + if (_exampleModeDemoKeysSent) + { + return; + } + + // Subscribe to IsModalChanged event on the TopRunnable + if (e.State.Runnable is Runnable { } runnable) + { + 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 + SessionBegun -= OnSessionBegunForExample; + } + + private void OnIsModalChangedForExample (object? sender, EventArgs e) + { + // Only send demo keys once, when a runnable becomes modal (not when it stops being modal) + if (_exampleModeDemoKeysSent || !e.Value) + { + return; + } + + // Mark that we've sent the keys + _exampleModeDemoKeysSent = true; + + // Unsubscribe - we only need to do this once + if (TopRunnable is { }) + { + TopRunnable.IsModalChanged -= OnIsModalChangedForExample; + } + + // Send demo keys from assembly attributes + SendDemoKeys (); + } + + private void SendDemoKeys () + { + // 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) + { + return; + } + + // Look for ExampleDemoKeyStrokesAttribute + List demoKeyAttributes = assembly.GetCustomAttributes (typeof (ExampleDemoKeyStrokesAttribute), false) + .OfType () + .ToList (); + + if (!demoKeyAttributes.Any ()) + { + return; + } + + // Sort by Order and collect all keystrokes + IOrderedEnumerable sortedSequences = demoKeyAttributes.OrderBy (a => a.Order); + + // Default delay between keys is 100ms + int currentDelay = 100; + + // Track cumulative timeout for scheduling + int cumulativeTimeout = 0; + + foreach (ExampleDemoKeyStrokesAttribute attr in sortedSequences) + { + // Handle KeyStrokes array + if (attr.KeyStrokes is not { Length: > 0 }) + { + continue; + } + + foreach (string keyStr in attr.KeyStrokes) + { + // 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 (Key.TryParse (keyStr, out Key? key)) + { + cumulativeTimeout += currentDelay; + + // 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 } diff --git a/Terminal.Gui/App/ApplicationImpl.Run.cs b/Terminal.Gui/App/ApplicationImpl.Run.cs index 1e037fee28..ec946f6629 100644 --- a/Terminal.Gui/App/ApplicationImpl.Run.cs +++ b/Terminal.Gui/App/ApplicationImpl.Run.cs @@ -174,6 +174,7 @@ public void Invoke (Action action) runnable.RaiseIsRunningChangedEvent (true); runnable.RaiseIsModalChangedEvent (true); + //RaiseIteration (); LayoutAndDraw (); return token; diff --git a/Terminal.Gui/Drivers/FakeDriver/FakeComponentFactory.cs b/Terminal.Gui/Drivers/FakeDriver/FakeComponentFactory.cs index 5f4284bdc5..5b023afca0 100644 --- a/Terminal.Gui/Drivers/FakeDriver/FakeComponentFactory.cs +++ b/Terminal.Gui/Drivers/FakeDriver/FakeComponentFactory.cs @@ -8,10 +8,6 @@ namespace Terminal.Gui.Drivers; /// public class FakeComponentFactory : ComponentFactoryImpl { - private readonly FakeInput? _input; - private readonly IOutput? _output; - private readonly ISizeMonitor? _sizeMonitor; - /// /// Creates a new FakeComponentFactory with optional output capture. /// @@ -25,12 +21,9 @@ public FakeComponentFactory (FakeInput? input = null, IOutput? output = null, IS _sizeMonitor = sizeMonitor; } - - /// - public override ISizeMonitor CreateSizeMonitor (IOutput consoleOutput, IOutputBuffer outputBuffer) - { - return _sizeMonitor ?? new SizeMonitorImpl (consoleOutput); - } + private readonly FakeInput? _input; + private readonly IOutput? _output; + private readonly ISizeMonitor? _sizeMonitor; /// public override IInput CreateInput () @@ -42,8 +35,11 @@ public override IInput CreateInput () public override IInputProcessor CreateInputProcessor (ConcurrentQueue inputBuffer) { return new FakeInputProcessor (inputBuffer); } /// - public override IOutput CreateOutput () + public override IOutput CreateOutput () { return _output ?? new FakeOutput (); } + + /// + public override ISizeMonitor CreateSizeMonitor (IOutput consoleOutput, IOutputBuffer outputBuffer) { - return _output ?? new FakeOutput (); + return _sizeMonitor ?? new SizeMonitorImpl (consoleOutput); } } diff --git a/Terminal.Gui/Examples/DemoKeyStrokeSequence.cs b/Terminal.Gui/Examples/DemoKeyStrokeSequence.cs new file mode 100644 index 0000000000..cc85a44e9e --- /dev/null +++ b/Terminal.Gui/Examples/DemoKeyStrokeSequence.cs @@ -0,0 +1,18 @@ +namespace Terminal.Gui.Examples; + +/// +/// Represents a sequence of keystrokes to inject during example demonstration or testing. +/// +public class DemoKeyStrokeSequence +{ + /// + /// Gets or sets the array of keystroke names to inject. + /// Can include special "SetDelay:nnn" commands to change the delay between keys. + /// + public string [] KeyStrokes { get; set; } = []; + + /// + /// Gets or sets the order in which this sequence should be executed. + /// + public int Order { get; set; } = 0; +} diff --git a/Terminal.Gui/Examples/ExampleCategoryAttribute.cs b/Terminal.Gui/Examples/ExampleCategoryAttribute.cs new file mode 100644 index 0000000000..f22ce8fbfa --- /dev/null +++ b/Terminal.Gui/Examples/ExampleCategoryAttribute.cs @@ -0,0 +1,35 @@ +namespace Terminal.Gui.Examples; + +/// +/// Defines a category for an example application. +/// Apply this attribute to an assembly to associate it with one or more categories for organization and filtering. +/// +/// +/// +/// Multiple instances of this attribute can be applied to a single assembly to associate the example +/// with multiple categories. +/// +/// +/// +/// +/// [assembly: ExampleCategory("Text and Formatting")] +/// [assembly: ExampleCategory("Controls")] +/// +/// +[AttributeUsage (AttributeTargets.Assembly, AllowMultiple = true)] +public class ExampleCategoryAttribute : System.Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The category name. + public ExampleCategoryAttribute (string category) + { + Category = category; + } + + /// + /// Gets or sets the category name. + /// + public string Category { get; set; } +} diff --git a/Terminal.Gui/Examples/ExampleContext.cs b/Terminal.Gui/Examples/ExampleContext.cs new file mode 100644 index 0000000000..68d9e6292a --- /dev/null +++ b/Terminal.Gui/Examples/ExampleContext.cs @@ -0,0 +1,76 @@ +using System.Text.Json; + +namespace Terminal.Gui.Examples; + +/// +/// Defines the execution context for running an example application. +/// This context is used to configure how an example should be executed, including driver selection, +/// keystroke injection, timeouts, and metrics collection. +/// +public class ExampleContext +{ + /// + /// Gets or sets the name of the driver to use (e.g., "FakeDriver", "DotnetDriver"). + /// If , the default driver for the platform is used. + /// + public string? DriverName { get; set; } + + /// + /// Gets or sets the list of key names to inject into the example during execution. + /// Each string should be a valid key name that can be parsed by . + /// + public List KeysToInject { get; set; } = []; + + /// + /// Gets or sets the maximum time in milliseconds to allow the example to run before forcibly terminating it. + /// + public int TimeoutMs { get; set; } = 30000; + + /// + /// Gets or sets the maximum number of iterations to allow before stopping the example. + /// If set to -1, no iteration limit is enforced. + /// + public int MaxIterations { get; set; } = -1; + + /// + /// Gets or sets a value indicating whether to collect and report performance metrics during execution. + /// + public bool CollectMetrics { get; set; } = false; + + /// + /// Gets or sets the execution mode for the example. + /// + public ExecutionMode Mode { get; set; } = ExecutionMode.OutOfProcess; + + /// + /// The name of the environment variable used to pass the serialized + /// to example applications. + /// + public const string ENVIRONMENT_VARIABLE_NAME = "TERMGUI_TEST_CONTEXT"; + + /// + /// Serializes this context to a JSON string for passing via environment variables. + /// + /// A JSON string representation of this context. + public string ToJson () + { + return JsonSerializer.Serialize (this); + } + + /// + /// Deserializes a from a JSON string. + /// + /// The JSON string to deserialize. + /// The deserialized context, or if deserialization fails. + public static ExampleContext? FromJson (string json) + { + try + { + return JsonSerializer.Deserialize (json); + } + catch + { + return null; + } + } +} diff --git a/Terminal.Gui/Examples/ExampleDemoKeyStrokesAttribute.cs b/Terminal.Gui/Examples/ExampleDemoKeyStrokesAttribute.cs new file mode 100644 index 0000000000..ff2916a09e --- /dev/null +++ b/Terminal.Gui/Examples/ExampleDemoKeyStrokesAttribute.cs @@ -0,0 +1,50 @@ +namespace Terminal.Gui.Examples; + +/// +/// Defines keystrokes to be automatically injected when the example is run in demo or test mode. +/// Apply this attribute to an assembly to specify automated input sequences for demonstration or testing purposes. +/// +/// +/// +/// Multiple instances of this attribute can be applied to a single assembly to define a sequence +/// of keystroke injections. The property controls the execution sequence. +/// +/// +/// Keystrokes can include special "SetDelay:nnn" entries to change the delay between subsequent keys. +/// The default delay is 100ms. For example: KeyStrokes = ["SetDelay:500", "Enter", "SetDelay:100", "Tab"] +/// +/// +/// +/// +/// [assembly: ExampleDemoKeyStrokes(RepeatKey = "CursorDown", RepeatCount = 5, Order = 1)] +/// [assembly: ExampleDemoKeyStrokes(KeyStrokes = ["SetDelay:500", "Enter", "SetDelay:100", "Esc"], Order = 2)] +/// +/// +[AttributeUsage (AttributeTargets.Assembly, AllowMultiple = true)] +public class ExampleDemoKeyStrokesAttribute : System.Attribute +{ + /// + /// Gets or sets an array of keystroke names to inject. + /// Each string should be a valid key name that can be parsed by , + /// or a special "SetDelay:nnn" command to change the delay between subsequent keys. + /// + public string []? KeyStrokes { get; set; } + + /// + /// Gets or sets the name of a single key to repeat multiple times. + /// This is a convenience for repeating the same keystroke. + /// + public string? RepeatKey { get; set; } + + /// + /// Gets or sets the number of times to repeat . + /// Only used when is specified. + /// + public int RepeatCount { get; set; } = 1; + + /// + /// Gets or sets the order in which this keystroke sequence should be executed + /// relative to other instances. + /// + public int Order { get; set; } = 0; +} diff --git a/Terminal.Gui/Examples/ExampleDiscovery.cs b/Terminal.Gui/Examples/ExampleDiscovery.cs new file mode 100644 index 0000000000..5423f4ed9b --- /dev/null +++ b/Terminal.Gui/Examples/ExampleDiscovery.cs @@ -0,0 +1,120 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace Terminal.Gui.Examples; + +/// +/// Provides methods for discovering example applications by scanning assemblies for example metadata attributes. +/// +public static class ExampleDiscovery +{ + /// + /// Discovers examples from the specified assembly file paths. + /// + /// The paths to assembly files to scan for examples. + /// An enumerable of objects for each discovered example. + [RequiresUnreferencedCode ("Calls System.Reflection.Assembly.LoadFrom")] + [RequiresDynamicCode ("Calls System.Reflection.Assembly.LoadFrom")] + public static IEnumerable DiscoverFromFiles (params string [] assemblyPaths) + { + foreach (string path in assemblyPaths) + { + if (!File.Exists (path)) + { + continue; + } + + Assembly? asm = null; + + try + { + asm = Assembly.LoadFrom (path); + } + catch + { + // Skip assemblies that can't be loaded + continue; + } + + ExampleMetadataAttribute? metadata = asm.GetCustomAttribute (); + + if (metadata is null) + { + continue; + } + + ExampleInfo info = new () + { + Name = metadata.Name, + Description = metadata.Description, + AssemblyPath = path, + Categories = asm.GetCustomAttributes () + .Select (c => c.Category) + .ToList (), + DemoKeyStrokes = ParseDemoKeyStrokes (asm) + }; + + yield return info; + } + } + + /// + /// Discovers examples from assemblies in the specified directory. + /// + /// The directory to search for assembly files. + /// The search pattern for assembly files (default is "*.dll"). + /// The search option for traversing subdirectories. + /// An enumerable of objects for each discovered example. + [RequiresUnreferencedCode ("Calls System.Reflection.Assembly.LoadFrom")] + [RequiresDynamicCode ("Calls System.Reflection.Assembly.LoadFrom")] + public static IEnumerable DiscoverFromDirectory ( + string directory, + string searchPattern = "*.dll", + SearchOption searchOption = SearchOption.AllDirectories + ) + { + if (!Directory.Exists (directory)) + { + return []; + } + + string [] assemblyPaths = Directory.GetFiles (directory, searchPattern, searchOption); + + return DiscoverFromFiles (assemblyPaths); + } + + private static List ParseDemoKeyStrokes (Assembly assembly) + { + List sequences = new (); + + foreach (ExampleDemoKeyStrokesAttribute attr in assembly.GetCustomAttributes ()) + { + List keys = new (); + + if (attr.KeyStrokes is { Length: > 0 }) + { + keys.AddRange (attr.KeyStrokes); + } + + if (!string.IsNullOrEmpty (attr.RepeatKey)) + { + for (var i = 0; i < attr.RepeatCount; i++) + { + keys.Add (attr.RepeatKey); + } + } + + if (keys.Count > 0) + { + sequences.Add ( + new () + { + KeyStrokes = keys.ToArray (), + Order = attr.Order + }); + } + } + + return sequences.OrderBy (s => s.Order).ToList (); + } +} diff --git a/Terminal.Gui/Examples/ExampleInfo.cs b/Terminal.Gui/Examples/ExampleInfo.cs new file mode 100644 index 0000000000..40fd868663 --- /dev/null +++ b/Terminal.Gui/Examples/ExampleInfo.cs @@ -0,0 +1,41 @@ +namespace Terminal.Gui.Examples; + +/// +/// Contains information about a discovered example application. +/// +public class ExampleInfo +{ + /// + /// Gets or sets the display name of the example. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets a description of what the example demonstrates. + /// + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets the full path to the example's assembly file. + /// + public string AssemblyPath { get; set; } = string.Empty; + + /// + /// Gets or sets the list of categories this example belongs to. + /// + public List Categories { get; set; } = new (); + + /// + /// Gets or sets the demo keystroke sequences defined for this example. + /// + public List DemoKeyStrokes { get; set; } = new (); + + /// + /// Returns a string representation of this example info. + /// + /// A string containing the name and description. + public override string ToString () + { + return $"{Name}: {Description}"; + } +} diff --git a/Terminal.Gui/Examples/ExampleMetadataAttribute.cs b/Terminal.Gui/Examples/ExampleMetadataAttribute.cs new file mode 100644 index 0000000000..6416cbdda0 --- /dev/null +++ b/Terminal.Gui/Examples/ExampleMetadataAttribute.cs @@ -0,0 +1,41 @@ +namespace Terminal.Gui.Examples; + +/// +/// Defines metadata (Name and Description) for an example application. +/// Apply this attribute to an assembly to mark it as an example that can be discovered and run. +/// +/// +/// +/// This attribute is used by the example discovery system to identify and describe standalone example programs. +/// Each example should have exactly one applied to its assembly. +/// +/// +/// +/// +/// [assembly: ExampleMetadata("Character Map", "Unicode character viewer and selector")] +/// +/// +[AttributeUsage (AttributeTargets.Assembly)] +public class ExampleMetadataAttribute : System.Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The display name of the example. + /// A brief description of what the example demonstrates. + public ExampleMetadataAttribute (string name, string description) + { + Name = name; + Description = description; + } + + /// + /// Gets or sets the display name of the example. + /// + public string Name { get; set; } + + /// + /// Gets or sets a brief description of what the example demonstrates. + /// + public string Description { get; set; } +} diff --git a/Terminal.Gui/Examples/ExampleMetrics.cs b/Terminal.Gui/Examples/ExampleMetrics.cs new file mode 100644 index 0000000000..bf8f2069bb --- /dev/null +++ b/Terminal.Gui/Examples/ExampleMetrics.cs @@ -0,0 +1,52 @@ +namespace Terminal.Gui.Examples; + +/// +/// Contains performance and execution metrics collected during an example's execution. +/// +public class ExampleMetrics +{ + /// + /// Gets or sets the time when the example started. + /// + public DateTime StartTime { get; set; } + + /// + /// Gets or sets the time when initialization completed. + /// + public DateTime? InitializedAt { get; set; } + + /// + /// Gets or sets a value indicating whether initialization completed successfully. + /// + public bool InitializedSuccessfully { get; set; } + + /// + /// Gets or sets the number of iterations executed. + /// + public int IterationCount { get; set; } + + /// + /// Gets or sets the time when shutdown began. + /// + public DateTime? ShutdownAt { get; set; } + + /// + /// Gets or sets a value indicating whether shutdown completed gracefully. + /// + public bool ShutdownGracefully { get; set; } + + /// + /// Gets or sets the number of times the screen was cleared. + /// + public int ClearedContentCount { get; set; } + + /// + /// Gets or sets the number of times views were drawn. + /// + public int DrawCompleteCount { get; set; } + + /// + /// Gets or sets the number of times views were laid out. + /// + public int LaidOutCount { get; set; } +} diff --git a/Terminal.Gui/Examples/ExampleResult.cs b/Terminal.Gui/Examples/ExampleResult.cs new file mode 100644 index 0000000000..32049d0b88 --- /dev/null +++ b/Terminal.Gui/Examples/ExampleResult.cs @@ -0,0 +1,42 @@ +namespace Terminal.Gui.Examples; + +/// +/// Contains the result of running an example application. +/// +public class ExampleResult +{ + /// + /// Gets or sets a value indicating whether the example completed successfully. + /// + public bool Success { get; set; } + + /// + /// Gets or sets the exit code of the example process (for out-of-process execution). + /// + public int? ExitCode { get; set; } + + /// + /// Gets or sets a value indicating whether the example timed out. + /// + public bool TimedOut { get; set; } + + /// + /// Gets or sets any error message that occurred during execution. + /// + public string? ErrorMessage { get; set; } + + /// + /// Gets or sets the performance metrics collected during execution. + /// + public ExampleMetrics? Metrics { get; set; } + + /// + /// Gets or sets the standard output captured during execution. + /// + public string? StandardOutput { get; set; } + + /// + /// Gets or sets the standard error captured during execution. + /// + public string? StandardError { get; set; } +} diff --git a/Terminal.Gui/Examples/ExampleRunner.cs b/Terminal.Gui/Examples/ExampleRunner.cs new file mode 100644 index 0000000000..aa96c3ac99 --- /dev/null +++ b/Terminal.Gui/Examples/ExampleRunner.cs @@ -0,0 +1,195 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Terminal.Gui.Examples; + +/// +/// Provides methods for running example applications in various execution modes. +/// +public static class ExampleRunner +{ + /// + /// Runs an example with the specified context. + /// + /// The example information. + /// The execution context. + /// The result of running the example. + [RequiresUnreferencedCode ("Calls System.Reflection.Assembly.LoadFrom")] + [RequiresDynamicCode ("Calls System.Reflection.Assembly.LoadFrom")] + public static ExampleResult Run (ExampleInfo example, ExampleContext context) + { + return context.Mode == ExecutionMode.InProcess + ? RunInProcess (example, context) + : RunOutOfProcess (example, context); + } + + private static ExampleMetrics? ExtractMetricsFromOutput (string output) + { + // Look for the metrics marker in the output + Match match = Regex.Match (output, @"###TERMGUI_METRICS:(.+?)###"); + + if (!match.Success) + { + return null; + } + + try + { + return JsonSerializer.Deserialize (match.Groups [1].Value); + } + catch + { + return null; + } + } + + [RequiresUnreferencedCode ("Calls System.Reflection.Assembly.LoadFrom")] + [RequiresDynamicCode ("Calls System.Reflection.Assembly.LoadFrom")] + private static ExampleResult RunInProcess (ExampleInfo example, ExampleContext context) + { + Environment.SetEnvironmentVariable ( + ExampleContext.ENVIRONMENT_VARIABLE_NAME, + context.ToJson ()); + + try + { + Assembly asm = Assembly.LoadFrom (example.AssemblyPath); + MethodInfo? entryPoint = asm.EntryPoint; + + if (entryPoint is null) + { + return new () + { + Success = false, + ErrorMessage = "Assembly does not have an entry point" + }; + } + + ParameterInfo [] parameters = entryPoint.GetParameters (); + + Task executionTask = Task.Run (() => + { + object? result = null; + + if (parameters.Length == 0) + { + result = entryPoint.Invoke (null, null); + } + else if (parameters.Length == 1 && parameters [0].ParameterType == typeof (string [])) + { + result = entryPoint.Invoke (null, [Array.Empty ()]); + } + else + { + throw new InvalidOperationException ("Entry point has unsupported signature"); + } + + // If entry point returns Task, wait for it + if (result is Task task) + { + task.GetAwaiter ().GetResult (); + } + }); + + bool completed = executionTask.Wait (context.TimeoutMs); + + if (!completed) + { + // reset terminal + Console.Clear (); + return new () + { + Success = false, + TimedOut = true + }; + } + + if (executionTask.Exception is { }) + { + throw executionTask.Exception.GetBaseException (); + } + + return new () + { + Success = true + }; + } + catch (Exception ex) + { + return new () + { + Success = false, + ErrorMessage = ex.ToString () + }; + } + finally + { + Environment.SetEnvironmentVariable (ExampleContext.ENVIRONMENT_VARIABLE_NAME, null); + } + } + + private static ExampleResult RunOutOfProcess (ExampleInfo example, ExampleContext context) + { + ProcessStartInfo psi = new () + { + FileName = "dotnet", + Arguments = $"\"{example.AssemblyPath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + psi.Environment [ExampleContext.ENVIRONMENT_VARIABLE_NAME] = context.ToJson (); + + using Process? process = Process.Start (psi); + + if (process is null) + { + return new () + { + Success = false, + ErrorMessage = "Failed to start process" + }; + } + + bool exited = process.WaitForExit (context.TimeoutMs); + string stdout = process.StandardOutput.ReadToEnd (); + string stderr = process.StandardError.ReadToEnd (); + + if (!exited) + { + try + { + const bool KILL_ENTIRE_PROCESS_TREE = true; + process.Kill (KILL_ENTIRE_PROCESS_TREE); + } + catch + { + // Ignore errors killing the process + } + + return new () + { + Success = false, + TimedOut = true, + StandardOutput = stdout, + StandardError = stderr + }; + } + + ExampleMetrics? metrics = ExtractMetricsFromOutput (stdout); + + return new () + { + Success = process.ExitCode == 0, + ExitCode = process.ExitCode, + StandardOutput = stdout, + StandardError = stderr, + Metrics = metrics + }; + } +} diff --git a/Terminal.Gui/Examples/ExecutionMode.cs b/Terminal.Gui/Examples/ExecutionMode.cs new file mode 100644 index 0000000000..42cd7ff47b --- /dev/null +++ b/Terminal.Gui/Examples/ExecutionMode.cs @@ -0,0 +1,19 @@ +namespace Terminal.Gui.Examples; + +/// +/// Defines the execution mode for running an example application. +/// +public enum ExecutionMode +{ + /// + /// Run the example in a separate process. + /// This provides full isolation but makes debugging more difficult. + /// + OutOfProcess, + + /// + /// Run the example in the same process by loading its assembly and invoking its entry point. + /// This allows for easier debugging but may have side effects from shared process state. + /// + InProcess +} diff --git a/Terminal.Gui/ViewBase/Runnable/Runnable.cs b/Terminal.Gui/ViewBase/Runnable/Runnable.cs index d513633549..6337c950ce 100644 --- a/Terminal.Gui/ViewBase/Runnable/Runnable.cs +++ b/Terminal.Gui/ViewBase/Runnable/Runnable.cs @@ -170,6 +170,10 @@ protected virtual void OnIsRunningChanged (bool newIsRunning) /// public void RaiseIsModalChangedEvent (bool newIsModal) { + // Layout may need to change when modal state changes + SetNeedsLayout (); + SetNeedsDraw (); + if (newIsModal) { // Set focus to self if becoming modal @@ -191,9 +195,6 @@ public void RaiseIsModalChangedEvent (bool newIsModal) EventArgs args = new (newIsModal); IsModalChanged?.Invoke (this, args); - // Layout may need to change when modal state changes - SetNeedsLayout (); - SetNeedsDraw (); } /// diff --git a/Terminal.Gui/ViewBase/View.Command.cs b/Terminal.Gui/ViewBase/View.Command.cs index ca8de67a16..0bc3175b53 100644 --- a/Terminal.Gui/ViewBase/View.Command.cs +++ b/Terminal.Gui/ViewBase/View.Command.cs @@ -131,13 +131,13 @@ private void SetupCommands () // 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); } diff --git a/Terminal.Gui/Views/MessageBox.cs b/Terminal.Gui/Views/MessageBox.cs index 07fccc0695..fdc66cc0cb 100644 --- a/Terminal.Gui/Views/MessageBox.cs +++ b/Terminal.Gui/Views/MessageBox.cs @@ -610,7 +610,7 @@ params string [] buttons e.Handled = true; } - (s as View)?.App?.RequestStop (); + ((s as View)?.SuperView as Dialog)?.RequestStop (); }; } @@ -657,7 +657,6 @@ params string [] buttons 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 (); diff --git a/Terminal.sln b/Terminal.sln index aacab4c01c..99f06ecd24 100644 --- a/Terminal.sln +++ b/Terminal.sln @@ -127,6 +127,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FluentExample", "Examples\F EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RunnableWrapperExample", "Examples\RunnableWrapperExample\RunnableWrapperExample.csproj", "{26FDEE3C-9D1F-79A6-F48F-D0944C7F09F8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExampleRunner", "Examples\ExampleRunner\ExampleRunner.csproj", "{2CB35142-AAD4-D424-61D3-88F9C94AD62A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -209,6 +211,10 @@ Global {26FDEE3C-9D1F-79A6-F48F-D0944C7F09F8}.Debug|Any CPU.Build.0 = Debug|Any CPU {26FDEE3C-9D1F-79A6-F48F-D0944C7F09F8}.Release|Any CPU.ActiveCfg = Release|Any CPU {26FDEE3C-9D1F-79A6-F48F-D0944C7F09F8}.Release|Any CPU.Build.0 = Release|Any CPU + {2CB35142-AAD4-D424-61D3-88F9C94AD62A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CB35142-AAD4-D424-61D3-88F9C94AD62A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CB35142-AAD4-D424-61D3-88F9C94AD62A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CB35142-AAD4-D424-61D3-88F9C94AD62A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Tests/UnitTestsParallelizable/Application/ApplicationTests.cs b/Tests/UnitTestsParallelizable/Application/ApplicationTests.cs index e28381940f..f0270131a5 100644 --- a/Tests/UnitTestsParallelizable/Application/ApplicationTests.cs +++ b/Tests/UnitTestsParallelizable/Application/ApplicationTests.cs @@ -245,7 +245,6 @@ public void Run_Iteration_Fires () void Application_Iteration (object? sender, EventArgs e) { - iteration++; app.RequestStop (); } diff --git a/Tests/UnitTestsParallelizable/Examples/ExampleTests.cs b/Tests/UnitTestsParallelizable/Examples/ExampleTests.cs new file mode 100644 index 0000000000..070ed703ae --- /dev/null +++ b/Tests/UnitTestsParallelizable/Examples/ExampleTests.cs @@ -0,0 +1,157 @@ +#nullable enable +using System.Diagnostics.CodeAnalysis; +using Terminal.Gui.Examples; +using Xunit.Abstractions; + +namespace ApplicationTests.Examples; + +/// +/// Tests for the example discovery and execution infrastructure. +/// +public class ExampleTests +{ + private readonly ITestOutputHelper _output; + + public ExampleTests (ITestOutputHelper output) + { + _output = output; + } + + /// + /// Discovers all examples by looking for assemblies with ExampleMetadata attributes. + /// + /// Test data for all discovered examples. + [RequiresUnreferencedCode ("Calls ExampleDiscovery.DiscoverFromDirectory")] + [RequiresDynamicCode ("Calls ExampleDiscovery.DiscoverFromDirectory")] + public static IEnumerable AllExamples () + { + // Navigate from test assembly location to repository root, then to Examples directory + // Test output is typically at: Tests/UnitTestsParallelizable/bin/Debug/net8.0/ + // Examples are at: Examples/ + string examplesDir = Path.GetFullPath (Path.Combine (AppContext.BaseDirectory, "..", "..", "..", "..", "..", "Examples")); + + if (!Directory.Exists (examplesDir)) + { + return []; + } + + List examples = ExampleDiscovery.DiscoverFromDirectory (examplesDir).ToList (); + + if (examples.Count == 0) + { + return []; + } + + return examples.Select (e => new object [] { e }); + } + + [Theory] + [MemberData (nameof (AllExamples))] + public void Example_Has_Metadata (ExampleInfo example) + { + Assert.NotNull (example); + Assert.False (string.IsNullOrWhiteSpace (example.Name), "Example name should not be empty"); + Assert.False (string.IsNullOrWhiteSpace (example.Description), "Example description should not be empty"); + Assert.True (File.Exists (example.AssemblyPath), $"Example assembly should exist: {example.AssemblyPath}"); + + _output.WriteLine ($"Example: {example.Name}"); + _output.WriteLine ($" Description: {example.Description}"); + _output.WriteLine ($" Categories: {string.Join (", ", example.Categories)}"); + _output.WriteLine ($" Assembly: {example.AssemblyPath}"); + } + + [Theory] + [MemberData (nameof (AllExamples))] + public void All_Examples_Quit_And_Init_Shutdown_Properly_OutOfProcess (ExampleInfo example) + { + _output.WriteLine ($"Running example '{example.Name}' out-of-process"); + + ExampleContext context = new () + { + DriverName = "FakeDriver", + KeysToInject = new () { "Esc" }, + TimeoutMs = 5000, + CollectMetrics = false, + Mode = ExecutionMode.OutOfProcess + }; + + ExampleResult result = ExampleRunner.Run (example, context); + + if (!result.Success) + { + _output.WriteLine ($"Example failed: {result.ErrorMessage}"); + + if (!string.IsNullOrEmpty (result.StandardOutput)) + { + _output.WriteLine ($"Standard Output:\n{result.StandardOutput}"); + } + + if (!string.IsNullOrEmpty (result.StandardError)) + { + _output.WriteLine ($"Standard Error:\n{result.StandardError}"); + } + } + + Assert.True (result.Success, $"Example '{example.Name}' should complete successfully"); + Assert.False (result.TimedOut, $"Example '{example.Name}' should not timeout"); + Assert.Equal (0, result.ExitCode); + } + + [Theory] + [MemberData (nameof (AllExamples))] + public void All_Examples_Quit_And_Init_Shutdown_Properly_InProcess (ExampleInfo example) + { + _output.WriteLine ($"Running example '{example.Name}' in-process"); + + // Force a complete reset to ensure clean state + Application.ResetState (true); + + ExampleContext context = new () + { + DriverName = "FakeDriver", + KeysToInject = new () { "Esc" }, + TimeoutMs = 5000, + CollectMetrics = false, + Mode = ExecutionMode.InProcess + }; + + ExampleResult result = ExampleRunner.Run (example, context); + + if (!result.Success) + { + _output.WriteLine ($"Example failed: {result.ErrorMessage}"); + } + + // Reset state after in-process execution + Application.ResetState (true); + + Assert.True (result.Success, $"Example '{example.Name}' should complete successfully"); + Assert.False (result.TimedOut, $"Example '{example.Name}' should not timeout"); + } + + [Fact] + public void ExampleContext_Serialization_Works () + { + ExampleContext context = new () + { + DriverName = "FakeDriver", + KeysToInject = new () { "Esc", "Enter" }, + TimeoutMs = 5000, + MaxIterations = 100, + CollectMetrics = true, + Mode = ExecutionMode.InProcess + }; + + string json = context.ToJson (); + Assert.False (string.IsNullOrWhiteSpace (json)); + + ExampleContext? deserialized = ExampleContext.FromJson (json); + Assert.NotNull (deserialized); + Assert.Equal (context.DriverName, deserialized.DriverName); + Assert.Equal (context.TimeoutMs, deserialized.TimeoutMs); + Assert.Equal (context.MaxIterations, deserialized.MaxIterations); + Assert.Equal (context.CollectMetrics, deserialized.CollectMetrics); + Assert.Equal (context.Mode, deserialized.Mode); + Assert.Equal (context.KeysToInject.Count, deserialized.KeysToInject.Count); + } +} diff --git a/docs/issues/timeout-nested-run-bug.md b/docs/issues/timeout-nested-run-bug.md new file mode 100644 index 0000000000..2432b1bbe8 --- /dev/null +++ b/docs/issues/timeout-nested-run-bug.md @@ -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 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