diff --git a/Examples/FluentExample/FluentExample.csproj b/Examples/FluentExample/FluentExample.csproj new file mode 100644 index 000000000..2086af6ed --- /dev/null +++ b/Examples/FluentExample/FluentExample.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + preview + enable + + + + + diff --git a/Examples/FluentExample/Program.cs b/Examples/FluentExample/Program.cs new file mode 100644 index 000000000..e27caf26e --- /dev/null +++ b/Examples/FluentExample/Program.cs @@ -0,0 +1,143 @@ +// Fluent API example demonstrating IRunnable with automatic disposal and result extraction + +using Terminal.Gui.App; +using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +#if POST_4148 +// Run the application with fluent API - automatically creates, runs, and disposes the runnable + +// Display the result +if (Application.Create () + .Init () + .Run () + .Shutdown () is Color { } result) +{ + Console.WriteLine (@$"Selected Color: {(Color?)result}"); +} +else +{ + Console.WriteLine (@"No color selected"); +} +#else + +// Run using traditional approach +IApplication app = Application.Create (); +app.Init (); +var colorPicker = new ColorPickerView (); +app.Run (colorPicker); + +Color? resultColor = colorPicker.Result; + +colorPicker.Dispose (); +app.Shutdown (); + +if (resultColor is { } result) +{ + Console.WriteLine (@$"Selected Color: {(Color?)result}"); +} +else +{ + Console.WriteLine (@"No color selected"); +} + +#endif + +#if POST_4148 +/// +/// A runnable view that allows the user to select a color. +/// Demonstrates IRunnable pattern with automatic disposal. +/// +public class ColorPickerView : Runnable +{ + +#else +/// +/// A runnable view that allows the user to select a color. +/// Uses the traditional approach without automatic disposal/Fluent API. +/// +public class ColorPickerView : Toplevel +{ + public Color? Result { get; set; } + +#endif + public ColorPickerView () + { + Title = "Select a Color (Esc to quit)"; + BorderStyle = LineStyle.Single; + Height = Dim.Auto (); + Width = Dim.Auto (); + + // Add instructions + var instructions = new Label + { + Text = "Use arrow keys to select a color, Enter to accept", + X = Pos.Center (), + Y = 0 + }; + + // Create color picker + ColorPicker colorPicker = new () + { + X = Pos.Center (), + Y = Pos.Bottom (instructions), + Style = new ColorPickerStyle () + { + ShowColorName = true, + ShowTextFields = true + } + }; + colorPicker.ApplyStyleChanges (); + + // Create OK button + Button okButton = new () + { + Title = "_OK", + X = Pos.Align (Alignment.Center), + Y = Pos.AnchorEnd (), + IsDefault = true + }; + + okButton.Accepting += (s, e) => + { + // Extract result before stopping + Result = colorPicker.SelectedColor; + RequestStop (); + e.Handled = true; + }; + + // Create Cancel button + Button cancelButton = new () + { + Title = "_Cancel", + X = Pos.Align (Alignment.Center), + Y = Pos.AnchorEnd () + }; + + cancelButton.Accepting += (s, e) => + { + // Don't set result - leave as null + RequestStop (); + e.Handled = true; + }; + + // Add views + Add (instructions, colorPicker, okButton, cancelButton); + } + +#if POST_4148 + protected override bool OnIsRunningChanging (bool oldIsRunning, bool newIsRunning) + { + // Alternative place to extract result before stopping + // This is called before the view is removed from the stack + if (!newIsRunning && Result is null) + { + // User pressed Esc - could extract current selection here + // Result = _colorPicker.SelectedColor; + } + + return base.OnIsRunningChanging (oldIsRunning, newIsRunning); + } +#endif +} diff --git a/Examples/RunnableWrapperExample/Program.cs b/Examples/RunnableWrapperExample/Program.cs new file mode 100644 index 000000000..41a2df32b --- /dev/null +++ b/Examples/RunnableWrapperExample/Program.cs @@ -0,0 +1,165 @@ +// Example demonstrating how to make ANY View runnable without implementing IRunnable + +using Terminal.Gui.App; +using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +IApplication app = Application.Create (); +app.Init (); + +// Example 1: Use extension method with result extraction +var textField = new TextField { Width = 40, Text = "Default text" }; +textField.Title = "Enter your name"; +textField.BorderStyle = LineStyle.Single; + +var textRunnable = textField.AsRunnable (tf => tf.Text); +app.Run (textRunnable); + +if (textRunnable.Result is { } name) +{ + MessageBox.Query ("Result", $"You entered: {name}", "OK"); +} +else +{ + MessageBox.Query ("Result", "Canceled", "OK"); +} +textRunnable.Dispose (); + +// Example 2: Use IApplication.RunView() for one-liner +var selectedColor = app.RunView ( + new ColorPicker + { + Title = "Pick a Color", + BorderStyle = LineStyle.Single + }, + cp => cp.SelectedColor); + +MessageBox.Query ("Result", $"Selected color: {selectedColor}", "OK"); + +// Example 3: FlagSelector with typed enum result +var flagSelector = new FlagSelector +{ + Title = "Choose Styles", + BorderStyle = LineStyle.Single +}; + +var flagsRunnable = flagSelector.AsRunnable (fs => fs.Value); +app.Run (flagsRunnable); + +MessageBox.Query ("Result", $"Selected styles: {flagsRunnable.Result}", "OK"); +flagsRunnable.Dispose (); + +// Example 4: Any View without result extraction +var label = new Label +{ + Text = "Press Esc to continue...", + X = Pos.Center (), + Y = Pos.Center () +}; + +var labelRunnable = label.AsRunnable (); +app.Run (labelRunnable); + +// Can still access the wrapped view +MessageBox.Query ("Result", $"Label text was: {labelRunnable.WrappedView.Text}", "OK"); +labelRunnable.Dispose (); + +// Example 5: Complex custom View made runnable +var formView = CreateCustomForm (); +var formRunnable = formView.AsRunnable (ExtractFormData); + +app.Run (formRunnable); + +if (formRunnable.Result is { } formData) +{ + MessageBox.Query ( + "Form Results", + $"Name: {formData.Name}\nAge: {formData.Age}\nAgreed: {formData.Agreed}", + "OK"); +} +formRunnable.Dispose (); + +app.Shutdown (); + +// Helper method to create a custom form +View CreateCustomForm () +{ + var form = new View + { + Title = "User Information", + BorderStyle = LineStyle.Single, + Width = 50, + Height = 10 + }; + + var nameField = new TextField + { + Id = "nameField", + X = 10, + Y = 1, + Width = 30 + }; + + var ageField = new TextField + { + Id = "ageField", + X = 10, + Y = 3, + Width = 10 + }; + + var agreeCheckbox = new CheckBox + { + Id = "agreeCheckbox", + Title = "I agree to terms", + X = 10, + Y = 5 + }; + + var okButton = new Button + { + Title = "OK", + X = Pos.Center (), + Y = 7, + IsDefault = true + }; + + okButton.Accepting += (s, e) => + { + form.App?.RequestStop (); + e.Handled = true; + }; + + form.Add (new Label { Text = "Name:", X = 2, Y = 1 }); + form.Add (nameField); + form.Add (new Label { Text = "Age:", X = 2, Y = 3 }); + form.Add (ageField); + form.Add (agreeCheckbox); + form.Add (okButton); + + return form; +} + +// Helper method to extract data from the custom form +FormData ExtractFormData (View form) +{ + var nameField = form.SubViews.FirstOrDefault (v => v.Id == "nameField") as TextField; + var ageField = form.SubViews.FirstOrDefault (v => v.Id == "ageField") as TextField; + var agreeCheckbox = form.SubViews.FirstOrDefault (v => v.Id == "agreeCheckbox") as CheckBox; + + return new FormData + { + Name = nameField?.Text ?? string.Empty, + Age = int.TryParse (ageField?.Text, out int age) ? age : 0, + Agreed = agreeCheckbox?.CheckedState == CheckState.Checked + }; +} + +// Result type for custom form +record FormData +{ + public string Name { get; init; } = string.Empty; + public int Age { get; init; } + public bool Agreed { get; init; } +} diff --git a/Examples/RunnableWrapperExample/RunnableWrapperExample.csproj b/Examples/RunnableWrapperExample/RunnableWrapperExample.csproj new file mode 100644 index 000000000..7e34acedb --- /dev/null +++ b/Examples/RunnableWrapperExample/RunnableWrapperExample.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + latest + + + + + + + diff --git a/Examples/UICatalog/Scenarios/EditorsAndHelpers/AllViewsView.cs b/Examples/UICatalog/Scenarios/EditorsAndHelpers/AllViewsView.cs index 862cc2083..f8f78ac6f 100644 --- a/Examples/UICatalog/Scenarios/EditorsAndHelpers/AllViewsView.cs +++ b/Examples/UICatalog/Scenarios/EditorsAndHelpers/AllViewsView.cs @@ -4,6 +4,7 @@ namespace UICatalog.Scenarios; public class AllViewsView : View { private const int MAX_VIEW_FRAME_HEIGHT = 25; + public AllViewsView () { CanFocus = true; @@ -24,6 +25,7 @@ public class AllViewsView : View AddCommand (Command.Down, () => ScrollVertical (1)); AddCommand (Command.PageUp, () => ScrollVertical (-SubViews.OfType ().First ().Frame.Height)); AddCommand (Command.PageDown, () => ScrollVertical (SubViews.OfType ().First ().Frame.Height)); + AddCommand ( Command.Start, () => @@ -32,6 +34,7 @@ public class AllViewsView : View return true; }); + AddCommand ( Command.End, () => @@ -65,12 +68,12 @@ public class AllViewsView : View MouseBindings.Add (MouseFlags.WheeledRight, Command.ScrollRight); } - /// + /// public override void EndInit () { base.EndInit (); - var allClasses = GetAllViewClassesCollection (); + List allClasses = GetAllViewClassesCollection (); View? previousView = null; @@ -95,19 +98,6 @@ public class AllViewsView : View } } - private static List GetAllViewClassesCollection () - { - List types = typeof (View).Assembly.GetTypes () - .Where ( - myType => myType is { IsClass: true, IsAbstract: false, IsPublic: true } - && myType.IsSubclassOf (typeof (View))) - .ToList (); - - types.Add (typeof (View)); - - return types; - } - private View? CreateView (Type type) { // If we are to create a generic Type @@ -125,12 +115,32 @@ public class AllViewsView : View } else { - typeArguments.Add (typeof (object)); + // Check if the generic parameter has constraints + Type [] constraints = arg.GetGenericParameterConstraints (); + + if (constraints.Length > 0) + { + // Use the first constraint type to satisfy the constraint + typeArguments.Add (constraints [0]); + } + else + { + typeArguments.Add (typeof (object)); + } } } // And change what type we are instantiating from MyClass to MyClass or MyClass - type = type.MakeGenericType (typeArguments.ToArray ()); + try + { + type = type.MakeGenericType (typeArguments.ToArray ()); + } + catch (ArgumentException ex) + { + Logging.Warning ($"Cannot create generic type {type} with arguments [{string.Join (", ", typeArguments.Select (t => t.Name))}]: {ex.Message}"); + + return null; + } } // Ensure the type does not contain any generic parameters @@ -164,6 +174,18 @@ public class AllViewsView : View return view; } + private static List GetAllViewClassesCollection () + { + List types = typeof (View).Assembly.GetTypes () + .Where (myType => myType is { IsClass: true, IsAbstract: false, IsPublic: true } + && myType.IsSubclassOf (typeof (View))) + .ToList (); + + types.Add (typeof (View)); + + return types; + } + private void OnViewInitialized (object? sender, EventArgs e) { if (sender is not View view) diff --git a/Terminal.Gui/App/Application.Run.cs b/Terminal.Gui/App/Application.Run.cs index d218c370d..9e6b2e064 100644 --- a/Terminal.Gui/App/Application.Run.cs +++ b/Terminal.Gui/App/Application.Run.cs @@ -20,7 +20,7 @@ public static partial class Application // Run (Begin -> Run -> Layout/Draw -> E set => ApplicationImpl.Instance.Keyboard.ArrangeKey = value; } - /// + /// [Obsolete ("The legacy static Application object is going away.")] public static SessionToken Begin (Toplevel toplevel) => ApplicationImpl.Instance.Begin (toplevel); @@ -82,7 +82,7 @@ public static partial class Application // Run (Begin -> Run -> Layout/Draw -> E [Obsolete ("The legacy static Application object is going away.")] public static void RequestStop (Toplevel? top = null) => ApplicationImpl.Instance.RequestStop (top); - /// + /// [Obsolete ("The legacy static Application object is going away.")] public static void End (SessionToken sessionToken) => ApplicationImpl.Instance.End (sessionToken); diff --git a/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs b/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs index 3e08a3f72..ed1e98741 100644 --- a/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs +++ b/Terminal.Gui/App/ApplicationImpl.Lifecycle.cs @@ -14,7 +14,7 @@ public partial class ApplicationImpl /// [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] - public void Init (string? driverName = null) + public IApplication Init (string? driverName = null) { if (Initialized) { @@ -71,11 +71,24 @@ public partial class ApplicationImpl SynchronizationContext.SetSynchronizationContext (new ()); MainThreadId = Thread.CurrentThread.ManagedThreadId; + + return this; } /// Shutdown an application initialized with . - public void Shutdown () + public object? Shutdown () { + // Extract result from framework-owned runnable before disposal + object? result = null; + IRunnable? runnableToDispose = FrameworkOwnedRunnable; + + if (runnableToDispose is { }) + { + // Extract the result using reflection to get the Result property value + var resultProperty = runnableToDispose.GetType().GetProperty("Result"); + result = resultProperty?.GetValue(runnableToDispose); + } + // Stop the coordinator if running Coordinator?.Stop (); @@ -97,6 +110,16 @@ public partial class ApplicationImpl } #endif + // Dispose the framework-owned runnable if it exists + if (runnableToDispose is { }) + { + if (runnableToDispose is IDisposable disposable) + { + disposable.Dispose(); + } + FrameworkOwnedRunnable = null; + } + // Clean up all application state (including sync context) // ResetState handles the case where Initialized is false ResetState (); @@ -113,6 +136,8 @@ public partial class ApplicationImpl // Clear the event to prevent memory leaks InitializedChanged = null; + + return result; } #if DEBUG @@ -156,9 +181,9 @@ public partial class ApplicationImpl TimedEvents?.StopAll (); // === 1. Stop all running toplevels === - foreach (Toplevel? t in SessionStack) + foreach (Toplevel t in SessionStack) { - t!.Running = false; + t.Running = false; } // === 2. Close and dispose popover === @@ -175,6 +200,7 @@ public partial class ApplicationImpl // === 3. Clean up toplevels === SessionStack.Clear (); + RunnableSessionStack?.Clear (); #if DEBUG_IDISPOSABLE @@ -222,6 +248,7 @@ public partial class ApplicationImpl // === 7. Clear navigation and screen state === ScreenChanged = null; + //Navigation = null; // === 8. Reset initialization state === diff --git a/Terminal.Gui/App/ApplicationImpl.Run.cs b/Terminal.Gui/App/ApplicationImpl.Run.cs index 3d03f7f60..944c64e09 100644 --- a/Terminal.Gui/App/ApplicationImpl.Run.cs +++ b/Terminal.Gui/App/ApplicationImpl.Run.cs @@ -165,7 +165,7 @@ public partial class ApplicationImpl /// [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] - public Toplevel Run (Func? errorHandler = null, string? driverName = null) { return Run (errorHandler, driverName); } + public Toplevel Run (Func? errorHandler = null, string? driverName = null) => Run (errorHandler, driverName); /// [RequiresUnreferencedCode ("AOT")] @@ -185,7 +185,6 @@ public partial class ApplicationImpl return top; } - /// public void Run (Toplevel view, Func? errorHandler = null) { @@ -222,7 +221,7 @@ public partial class ApplicationImpl if (StopAfterFirstIteration && firstIteration) { Logging.Information ("Run - Stopping after first iteration as requested"); - view.RequestStop (); + RequestStop ((Toplevel?)view); } firstIteration = false; @@ -291,7 +290,7 @@ public partial class ApplicationImpl } /// - public void RequestStop () { RequestStop (null); } + public void RequestStop () { RequestStop ((Toplevel?)null); } /// public void RequestStop (Toplevel? top) @@ -326,10 +325,10 @@ public partial class ApplicationImpl public ITimedEvents? TimedEvents => _timedEvents; /// - public object AddTimeout (TimeSpan time, Func callback) { return _timedEvents.Add (time, callback); } + public object AddTimeout (TimeSpan time, Func callback) => _timedEvents.Add (time, callback); /// - public bool RemoveTimeout (object token) { return _timedEvents.Remove (token); } + public bool RemoveTimeout (object token) => _timedEvents.Remove (token); /// public void Invoke (Action? action) @@ -353,7 +352,6 @@ public partial class ApplicationImpl ); } - /// public void Invoke (Action action) { @@ -377,4 +375,300 @@ public partial class ApplicationImpl } #endregion Timeouts and Invoke + + #region IRunnable Support + + /// + public RunnableSessionToken Begin (IRunnable runnable) + { + ArgumentNullException.ThrowIfNull (runnable); + + // Ensure the mouse is ungrabbed + if (Mouse.MouseGrabView is { }) + { + Mouse.UngrabMouse (); + } + + // Create session token + RunnableSessionToken token = new (runnable); + + // Set the App property if the runnable is a View (needed for IsRunning/IsModal checks) + if (runnable is View runnableView) + { + runnableView.App = this; + } + + // Get old IsRunning and IsModal values BEFORE any stack changes + bool oldIsRunning = runnable.IsRunning; + bool oldIsModalValue = runnable.IsModal; + + // Raise IsRunningChanging (false -> true) - can be canceled + if (runnable.RaiseIsRunningChanging (oldIsRunning, true)) + { + // Starting was canceled + return token; + } + + // Push token onto RunnableSessionStack (IsRunning becomes true) + RunnableSessionStack?.Push (token); + + // Update TopRunnable to the new top of stack + IRunnable? previousTop = null; + + // In Phase 1, Toplevel doesn't implement IRunnable yet + // In Phase 2, it will, and this will work properly + if (TopRunnable is IRunnable r) + { + previousTop = r; + } + + // Set TopRunnable (handles both Toplevel and IRunnable) + if (runnable is Toplevel tl) + { + TopRunnable = tl; + } + else if (runnable is View v) + { + // For now, we can't set a non-Toplevel View as TopRunnable + // This is a limitation of the current architecture + // In Phase 2, we'll make TopRunnable an IRunnable property + Logging.Warning ($"WIP on Issue #4148 - Runnable '{runnable}' is a View but not a Toplevel; cannot set as TopRunnable"); + } + + // Raise IsRunningChanged (now true) + runnable.RaiseIsRunningChangedEvent (true); + + // If there was a previous top, it's no longer modal + if (previousTop != null) + { + // Get old IsModal value (should be true before becoming non-modal) + bool oldIsModal = previousTop.IsModal; + + // Raise IsModalChanging (true -> false) + previousTop.RaiseIsModalChanging (oldIsModal, false); + + // IsModal is now false (derived property) + previousTop.RaiseIsModalChangedEvent (false); + } + + // New runnable becomes modal + // Raise IsModalChanging (false -> true) using the old value we captured earlier + runnable.RaiseIsModalChanging (oldIsModalValue, true); + + // IsModal is now true (derived property) + runnable.RaiseIsModalChangedEvent (true); + + // Initialize if needed + if (runnable is View view && !view.IsInitialized) + { + view.BeginInit (); + view.EndInit (); + + // Initialized event is raised by View.EndInit() + } + + // Initial Layout and draw + LayoutAndDraw (true); + + // Set focus + if (runnable is View viewToFocus && !viewToFocus.HasFocus) + { + viewToFocus.SetFocus (); + } + + if (PositionCursor ()) + { + Driver?.UpdateCursor (); + } + + return token; + } + + /// + public void Run (IRunnable runnable, Func? errorHandler = null) + { + ArgumentNullException.ThrowIfNull (runnable); + + if (!Initialized) + { + throw new NotInitializedException (nameof (Run)); + } + + // Begin the session (adds to stack, raises IsRunningChanging/IsRunningChanged) + RunnableSessionToken token = Begin (runnable); + + try + { + // All runnables block until RequestStop() is called + RunLoop (runnable, errorHandler); + } + finally + { + // End the session (raises IsRunningChanging/IsRunningChanged, pops from stack) + End (token); + } + } + + /// + public IApplication Run (Func? errorHandler = null) where TRunnable : IRunnable, new () + { + if (!Initialized) + { + throw new NotInitializedException (nameof (Run)); + } + + TRunnable runnable = new (); + + // Store the runnable for automatic disposal by Shutdown + FrameworkOwnedRunnable = runnable; + + Run (runnable, errorHandler); + + return this; + } + + private void RunLoop (IRunnable runnable, Func? errorHandler) + { + // Main loop - blocks until RequestStop() is called + // Note: IsRunning is a derived property (stack.Contains), so we check it each iteration + var firstIteration = true; + + while (runnable.IsRunning) + { + if (Coordinator is null) + { + throw new ($"{nameof (IMainLoopCoordinator)} inexplicably became null during Run"); + } + + try + { + // Process one iteration of the event loop + Coordinator.RunIteration (); + } + catch (Exception ex) + { + if (errorHandler is null || !errorHandler (ex)) + { + throw; + } + } + + if (StopAfterFirstIteration && firstIteration) + { + Logging.Information ("Run - Stopping after first iteration as requested"); + RequestStop (runnable); + } + + firstIteration = false; + } + } + + /// + public void End (RunnableSessionToken token) + { + ArgumentNullException.ThrowIfNull (token); + + if (token.Runnable is null) + { + return; // Already ended + } + + IRunnable runnable = token.Runnable; + + // Get old IsRunning value (should be true before stopping) + bool oldIsRunning = runnable.IsRunning; + + // Raise IsRunningChanging (true -> false) - can be canceled + // This is where Result should be extracted! + if (runnable.RaiseIsRunningChanging (oldIsRunning, false)) + { + // Stopping was canceled + return; + } + + // Current runnable is no longer modal + // Get old IsModal value (should be true before becoming non-modal) + bool oldIsModal = runnable.IsModal; + + // Raise IsModalChanging (true -> false) + runnable.RaiseIsModalChanging (oldIsModal, false); + + // IsModal is now false (will be false after pop) + runnable.RaiseIsModalChangedEvent (false); + + // Pop token from RunnableSessionStack (IsRunning becomes false) + if (RunnableSessionStack?.TryPop (out RunnableSessionToken? popped) == true && popped == token) + { + // Restore previous top runnable + if (RunnableSessionStack?.TryPeek (out RunnableSessionToken? previousToken) == true && previousToken?.Runnable is { }) + { + IRunnable? previousRunnable = previousToken.Runnable; + + // Update TopRunnable if it's a Toplevel + if (previousRunnable is Toplevel tl) + { + TopRunnable = tl; + } + + // Previous runnable becomes modal again + // Get old IsModal value (should be false before becoming modal again) + bool oldIsModalValue = previousRunnable.IsModal; + + // Raise IsModalChanging (false -> true) + previousRunnable.RaiseIsModalChanging (oldIsModalValue, true); + + // IsModal is now true (derived property) + previousRunnable.RaiseIsModalChangedEvent (true); + } + else + { + // No more runnables, clear TopRunnable + if (TopRunnable is IRunnable) + { + TopRunnable = null; + } + } + } + + // Raise IsRunningChanged (now false) + runnable.RaiseIsRunningChangedEvent (false); + + // Set focus to new TopRunnable if exists + if (TopRunnable is View viewToFocus && !viewToFocus.HasFocus) + { + viewToFocus.SetFocus (); + } + + // Clear the token + token.Runnable = null; + } + + /// + public void RequestStop (IRunnable? runnable) + { + // Get the runnable to stop + if (runnable is null) + { + // Try to get from TopRunnable + if (TopRunnable is IRunnable r) + { + runnable = r; + } + else + { + return; + } + } + + // For Toplevel, use the existing mechanism + if (runnable is Toplevel toplevel) + { + RequestStop (toplevel); + } + + // Note: The End() method will be called from the finally block in Run() + // and that's where IsRunningChanging/IsRunningChanged will be raised + } + + #endregion IRunnable Support } diff --git a/Terminal.Gui/App/ApplicationImpl.cs b/Terminal.Gui/App/ApplicationImpl.cs index 1aca088dd..2dc54cbda 100644 --- a/Terminal.Gui/App/ApplicationImpl.cs +++ b/Terminal.Gui/App/ApplicationImpl.cs @@ -25,10 +25,7 @@ public partial class ApplicationImpl : IApplication /// Configures the singleton instance of to use the specified backend implementation. /// /// - public static void SetInstance (IApplication? app) - { - _instance = app; - } + public static void SetInstance (IApplication? app) { _instance = app; } // Private static readonly Lazy instance of Application private static IApplication? _instance; @@ -42,7 +39,6 @@ public partial class ApplicationImpl : IApplication private string? _driverName; - #region Input private IMouse? _mouse; @@ -54,10 +50,7 @@ public partial class ApplicationImpl : IApplication { get { - if (_mouse is null) - { - _mouse = new MouseImpl { App = this }; - } + _mouse ??= new MouseImpl { App = this }; return _mouse; } @@ -73,10 +66,7 @@ public partial class ApplicationImpl : IApplication { get { - if (_keyboard is null) - { - _keyboard = new KeyboardImpl { App = this }; - } + _keyboard ??= new KeyboardImpl { App = this }; return _keyboard; } @@ -94,10 +84,7 @@ public partial class ApplicationImpl : IApplication { get { - if (_popover is null) - { - _popover = new () { App = this }; - } + _popover ??= new () { App = this }; return _popover; } @@ -111,10 +98,7 @@ public partial class ApplicationImpl : IApplication { get { - if (_navigation is null) - { - _navigation = new () { App = this }; - } + _navigation ??= new () { App = this }; return _navigation; } @@ -146,6 +130,12 @@ public partial class ApplicationImpl : IApplication /// public Toplevel? CachedSessionTokenToplevel { get; set; } + /// + public ConcurrentStack? RunnableSessionStack { get; } = new (); + + /// + public IRunnable? FrameworkOwnedRunnable { get; set; } + #endregion View Management /// diff --git a/Terminal.Gui/App/ApplicationRunnableExtensions.cs b/Terminal.Gui/App/ApplicationRunnableExtensions.cs new file mode 100644 index 000000000..7e706e9d5 --- /dev/null +++ b/Terminal.Gui/App/ApplicationRunnableExtensions.cs @@ -0,0 +1,158 @@ +namespace Terminal.Gui.App; + +/// +/// Extension methods for that enable running any as a runnable session. +/// +/// +/// These extensions provide convenience methods for wrapping views in +/// and running them in a single call, similar to how works. +/// +public static class ApplicationRunnableExtensions +{ + /// + /// Runs any View as a runnable session, extracting a typed result via a function. + /// + /// The type of view to run. + /// The type of result data to extract. + /// The application instance. Cannot be null. + /// The view to run as a blocking session. Cannot be null. + /// + /// Function that extracts the result from the view when stopping. + /// Called automatically when the runnable session ends. + /// + /// Optional handler for unhandled exceptions during the session. + /// The extracted result, or null if the session was canceled. + /// + /// Thrown if , , or is null. + /// + /// + /// + /// This method wraps the view in a , runs it as a blocking + /// session, and returns the extracted result. The wrapper is NOT disposed automatically; + /// the caller is responsible for disposal. + /// + /// + /// The result is extracted before the view is disposed, ensuring all data is still accessible. + /// + /// + /// + /// + /// var app = Application.Create(); + /// app.Init(); + /// + /// // Run a TextField and get the entered text + /// var text = app.RunView( + /// new TextField { Width = 40 }, + /// tf => tf.Text); + /// Console.WriteLine($"You entered: {text}"); + /// + /// // Run a ColorPicker and get the selected color + /// var color = app.RunView( + /// new ColorPicker(), + /// cp => cp.SelectedColor); + /// Console.WriteLine($"Selected color: {color}"); + /// + /// // Run a FlagSelector and get the selected flags + /// var flags = app.RunView( + /// new FlagSelector<SelectorStyles>(), + /// fs => fs.Value); + /// Console.WriteLine($"Selected styles: {flags}"); + /// + /// app.Shutdown(); + /// + /// + public static TResult? RunView ( + this IApplication app, + TView view, + Func resultExtractor, + Func? errorHandler = null) + where TView : View + { + if (app is null) + { + throw new ArgumentNullException (nameof (app)); + } + + if (view is null) + { + throw new ArgumentNullException (nameof (view)); + } + + if (resultExtractor is null) + { + throw new ArgumentNullException (nameof (resultExtractor)); + } + + var wrapper = new RunnableWrapper { WrappedView = view }; + + // Subscribe to IsRunningChanging to extract result when stopping + wrapper.IsRunningChanging += (s, e) => + { + if (!e.NewValue) // Stopping + { + wrapper.Result = resultExtractor (view); + } + }; + + app.Run (wrapper, errorHandler); + + return wrapper.Result; + } + + /// + /// Runs any View as a runnable session without result extraction. + /// + /// The type of view to run. + /// The application instance. Cannot be null. + /// The view to run as a blocking session. Cannot be null. + /// Optional handler for unhandled exceptions during the session. + /// The view that was run, allowing access to its state after the session ends. + /// Thrown if or is null. + /// + /// + /// This method wraps the view in a and runs it as a blocking + /// session. The wrapper is NOT disposed automatically; the caller is responsible for disposal. + /// + /// + /// Use this overload when you don't need automatic result extraction, but still want the view + /// to run as a blocking session. Access the view's properties directly after running. + /// + /// + /// + /// + /// var app = Application.Create(); + /// app.Init(); + /// + /// // Run a ColorPicker without automatic result extraction + /// var colorPicker = new ColorPicker(); + /// app.RunView(colorPicker); + /// + /// // Access the view's state directly + /// Console.WriteLine($"Selected: {colorPicker.SelectedColor}"); + /// + /// app.Shutdown(); + /// + /// + public static TView RunView ( + this IApplication app, + TView view, + Func? errorHandler = null) + where TView : View + { + if (app is null) + { + throw new ArgumentNullException (nameof (app)); + } + + if (view is null) + { + throw new ArgumentNullException (nameof (view)); + } + + var wrapper = new RunnableWrapper { WrappedView = view }; + + app.Run (wrapper, errorHandler); + + return view; + } +} diff --git a/Terminal.Gui/App/IApplication.cs b/Terminal.Gui/App/IApplication.cs index 88e1d2d1f..f663a351f 100644 --- a/Terminal.Gui/App/IApplication.cs +++ b/Terminal.Gui/App/IApplication.cs @@ -44,6 +44,7 @@ public interface IApplication /// The short name (e.g. "dotnet", "windows", "unix", or "fake") of the /// to use. If not specified the default driver for the platform will be used. /// + /// This instance for fluent API chaining. /// /// Call this method once per instance (or after has been called). /// @@ -52,17 +53,20 @@ public interface IApplication /// /// /// must be called when the application is closing (typically after - /// has returned) to ensure resources are cleaned up and terminal settings restored. + /// has returned) to ensure resources are cleaned up and terminal settings restored. /// /// - /// The function combines and + /// The function combines and /// into a single call. An application can use - /// without explicitly calling . + /// without explicitly calling . + /// + /// + /// Supports fluent API: Application.Create().Init().Run<MyView>().Shutdown() /// /// [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] - public void Init (string? driverName = null); + public IApplication Init (string? driverName = null); /// /// This event is raised after the and methods have been called. @@ -76,12 +80,25 @@ public interface IApplication bool Initialized { get; set; } /// Shutdown an application initialized with . + /// + /// The result from the last call, or if none. + /// Automatically disposes any runnable created by . + /// /// - /// Shutdown must be called for every call to or - /// to ensure all resources are cleaned - /// up (Disposed) and terminal settings are restored. + /// + /// Shutdown must be called for every call to or + /// to ensure all resources are cleaned + /// up (Disposed) and terminal settings are restored. + /// + /// + /// When used in a fluent chain with , this method automatically + /// disposes the runnable instance and extracts its result for return. + /// + /// + /// Supports fluent API: var result = Application.Create().Init().Run<MyView>().Shutdown() as MyResultType + /// /// - public void Shutdown (); + public object? Shutdown (); /// /// Resets the state of this instance. @@ -177,7 +194,7 @@ public interface IApplication /// . /// /// - /// When using or , + /// When using or , /// will be called automatically. /// /// @@ -225,7 +242,7 @@ public interface IApplication /// . /// /// - /// When using or , + /// When using or , /// will be called automatically. /// /// @@ -301,14 +318,16 @@ public interface IApplication /// /// This will cause to return. /// - /// This is equivalent to calling with as the parameter. + /// This is equivalent to calling with as the + /// parameter. /// /// void RequestStop (); /// Requests that the currently running Session stop. The Session will stop after the current iteration completes. /// - /// The to stop. If , stops the currently running . + /// The to stop. If , stops the currently running + /// . /// /// /// This will cause to return. @@ -324,7 +343,7 @@ public interface IApplication /// /// /// - /// Used primarily for unit testing. When , will be called + /// Used primarily for unit testing. When , will be called /// automatically after the first main loop iteration. /// /// @@ -386,6 +405,165 @@ public interface IApplication #endregion Toplevel Management + #region IRunnable Management + + /// + /// Gets the stack of all active runnable session tokens. + /// Sessions execute serially - the top of stack is the currently modal session. + /// + /// + /// + /// Session tokens are pushed onto the stack when is called and + /// popped when + /// completes. The stack grows during nested modal calls and + /// shrinks as they complete. + /// + /// + /// Only the top session () has exclusive keyboard/mouse input ( + /// = true). + /// All other sessions on the stack continue to be laid out, drawn, and receive iteration events ( + /// = true), + /// but they don't receive user input. + /// + /// + /// Stack during nested modals: + /// + /// RunnableSessionStack (top to bottom): + /// - MessageBox (TopRunnable, IsModal=true, IsRunning=true, has input) + /// - FileDialog (IsModal=false, IsRunning=true, continues to update/draw) + /// - MainWindow (IsModal=false, IsRunning=true, continues to update/draw) + /// + /// + /// + ConcurrentStack? RunnableSessionStack { get; } + + /// + /// Gets or sets the runnable that was created by for automatic disposal. + /// + /// + /// + /// When creates a runnable instance, it stores it here so + /// can automatically dispose it and extract its result. + /// + /// + /// This property is if was used + /// with an externally-created runnable. + /// + /// + IRunnable? FrameworkOwnedRunnable { get; set; } + + /// + /// Building block API: Creates a and prepares the provided + /// for + /// execution. Not usually called directly by applications. Use + /// instead. + /// + /// The to prepare execution for. + /// + /// The that needs to be passed to the + /// method upon + /// completion. + /// + /// + /// + /// This method prepares the provided for running. It adds this to the + /// , lays out the SubViews, focuses the first element, and draws the + /// runnable on the screen. This is usually followed by starting the main loop, and then the + /// method upon termination which will undo these changes. + /// + /// + /// Raises the , , + /// , and events. + /// + /// + RunnableSessionToken Begin (IRunnable runnable); + + /// + /// Runs a new Session with the provided runnable view. + /// + /// The runnable to execute. + /// Optional handler for unhandled exceptions (resumes when returns true, rethrows when null). + /// + /// + /// This method is used to start processing events for the main application, but it is also used to run other + /// modal views such as dialogs. + /// + /// + /// To make stop execution, call + /// or . + /// + /// + /// Calling is equivalent to calling + /// , followed by starting the main loop, and then calling + /// . + /// + /// + /// In RELEASE builds: When is any exceptions will be + /// rethrown. Otherwise, will be called. If + /// returns the main loop will resume; otherwise this method will exit. + /// + /// + void Run (IRunnable runnable, Func? errorHandler = null); + + /// + /// Creates and runs a new session with a of the specified type. + /// + /// The type of runnable to create and run. Must have a parameterless constructor. + /// Optional handler for unhandled exceptions (resumes when returns true, rethrows when null). + /// This instance for fluent API chaining. The created runnable is stored internally for disposal. + /// + /// + /// This is a convenience method that creates an instance of and runs it. + /// The framework owns the created instance and will automatically dispose it when is called. + /// + /// + /// To access the result, use which returns the result from . + /// + /// + /// Supports fluent API: var result = Application.Create().Init().Run<MyView>().Shutdown() as MyResultType + /// + /// + IApplication Run (Func? errorHandler = null) where TRunnable : IRunnable, new (); + + /// + /// Requests that the specified runnable session stop. + /// + /// The runnable to stop. If , stops the current . + /// + /// + /// This will cause to return. + /// + /// + /// Raises , , + /// , and events. + /// + /// + void RequestStop (IRunnable? runnable); + + /// + /// Building block API: Ends the session associated with the token and completes the execution of an + /// . + /// Not usually called directly by applications. + /// will automatically call this method when the session is stopped. + /// + /// + /// The returned by the + /// method. + /// + /// + /// + /// This method removes the from the , + /// raises the lifecycle events, and disposes the . + /// + /// + /// Raises , , + /// , and events. + /// + /// + void End (RunnableSessionToken sessionToken); + + #endregion IRunnable Management + #region Screen and Driver /// Gets or sets the console driver being used. diff --git a/Terminal.Gui/App/Runnable/IRunnable.cs b/Terminal.Gui/App/Runnable/IRunnable.cs new file mode 100644 index 000000000..2e6711d0c --- /dev/null +++ b/Terminal.Gui/App/Runnable/IRunnable.cs @@ -0,0 +1,233 @@ +namespace Terminal.Gui.App; + +/// +/// Non-generic base interface for runnable views. Provides common members without type parameter. +/// +/// +/// +/// This interface enables storing heterogeneous runnables in collections (e.g., +/// ) +/// while preserving type safety at usage sites via . +/// +/// +/// Most code should use directly. This base interface is primarily +/// for framework infrastructure (session management, stacking, etc.). +/// +/// +/// A runnable view executes as a self-contained blocking session with its own lifecycle, +/// event loop iteration, and focus management./> +/// blocks until +/// is called. +/// +/// +/// This interface follows the Terminal.Gui Cancellable Work Pattern (CWP) for all lifecycle events. +/// +/// +/// +/// +public interface IRunnable +{ + #region Running or not (added to/removed from RunnableSessionStack) + + /// + /// Gets whether this runnable session is currently running (i.e., on the + /// ). + /// + /// + /// + /// Read-only property derived from stack state. Returns if this runnable + /// is currently on the , otherwise. + /// + /// + /// Runnables are added to the stack during and removed in + /// . + /// + /// + bool IsRunning { get; } + + /// + /// Called by the framework to raise the event. + /// + /// The current value of . + /// The new value of (true = starting, false = stopping). + /// if the change was canceled; otherwise . + /// + /// + /// This method implements the Cancellable Work Pattern. It calls the protected virtual method first, + /// then raises the event if not canceled. + /// + /// + /// When is (stopping), this is the ideal place + /// for implementations to extract Result from views before the runnable is removed from the stack. + /// + /// + bool RaiseIsRunningChanging (bool oldIsRunning, bool newIsRunning); + + /// + /// Raised when is changing (e.g., when or + /// is called). + /// Can be canceled by setting to . + /// + /// + /// + /// Subscribe to this event to participate in the runnable lifecycle before state changes occur. + /// When is (stopping), + /// this is the ideal place to extract Result before views are disposed and to optionally + /// cancel the stop operation (e.g., prompt to save changes). + /// + /// + /// This event follows the Terminal.Gui Cancellable Work Pattern (CWP). + /// + /// + event EventHandler>? IsRunningChanging; + + /// + /// Called by the framework to raise the event. + /// + /// The new value of (true = started, false = stopped). + /// + /// This method is called after the state change has occurred and cannot be canceled. + /// + void RaiseIsRunningChangedEvent (bool newIsRunning); + + /// + /// Raised after has changed (after the runnable has been added to or removed from the + /// ). + /// + /// + /// + /// Subscribe to this event to perform post-state-change logic. When is + /// , + /// the runnable has started and is on the stack. When , the runnable has stopped and been + /// removed from the stack. + /// + /// + /// This event follows the Terminal.Gui Cancellable Work Pattern (CWP). + /// + /// + event EventHandler>? IsRunningChanged; + + #endregion Running or not (added to/removed from RunnableSessionStack) + + #region Modal or not (top of RunnableSessionStack or not) + + /// + /// Gets whether this runnable session is at the top of the and thus + /// exclusively receiving mouse and keyboard input. + /// + /// + /// + /// Read-only property derived from stack state. Returns if this runnable + /// is at the top of the stack (i.e., this == app.TopRunnable), otherwise. + /// + /// + /// The runnable at the top of the stack gets all mouse/keyboard input and thus is running "modally". + /// + /// + bool IsModal { get; } + + /// + /// Called by the framework to raise the event. + /// + /// The current value of . + /// The new value of (true = becoming modal/top, false = no longer modal). + /// if the change was canceled; otherwise . + /// + /// This method implements the Cancellable Work Pattern. It calls the protected virtual method first, + /// then raises the event if not canceled. + /// + bool RaiseIsModalChanging (bool oldIsModal, bool newIsModal); + + /// + /// Raised when this runnable is about to become modal (top of stack) or cease being modal. + /// Can be canceled by setting to . + /// + /// + /// + /// Subscribe to this event to participate in modal state transitions before they occur. + /// When is , the runnable is becoming modal (top + /// of stack). + /// When , another runnable is becoming modal and this one will no longer receive input. + /// + /// + /// This event follows the Terminal.Gui Cancellable Work Pattern (CWP). + /// + /// + event EventHandler>? IsModalChanging; + + /// + /// Called by the framework to raise the event. + /// + /// The new value of (true = became modal/top, false = no longer modal). + /// + /// This method is called after the modal state change has occurred and cannot be canceled. + /// + void RaiseIsModalChangedEvent (bool newIsModal); + + /// + /// Raised after this runnable has become modal (top of stack) or ceased being modal. + /// + /// + /// + /// Subscribe to this event to perform post-activation logic (e.g., setting focus, updating UI state). + /// When is , the runnable became modal (top of + /// stack). + /// When , the runnable is no longer modal (another runnable is on top). + /// + /// + /// This event follows the Terminal.Gui Cancellable Work Pattern (CWP). + /// + /// + event EventHandler>? IsModalChanged; + + #endregion Modal or not (top of RunnableSessionStack or not) +} + +/// +/// Defines a view that can be run as an independent blocking session with , +/// returning a typed result. +/// +/// +/// The type of result data returned when the session completes. +/// Common types: for button indices, for file paths, +/// custom types for complex form data. +/// +/// +/// +/// A runnable view executes as a self-contained blocking session with its own lifecycle, +/// event loop iteration, and focus management. blocks until +/// is called. +/// +/// +/// When is , the session was stopped without being accepted +/// (e.g., ESC key pressed, window closed). When non-, it contains the result data +/// extracted in (when stopping) before views are disposed. +/// +/// +/// Implementing does not require deriving from any specific +/// base class or using . These are orthogonal concerns. +/// +/// +/// This interface follows the Terminal.Gui Cancellable Work Pattern (CWP) for all lifecycle events. +/// +/// +/// +/// +public interface IRunnable : IRunnable +{ + /// + /// Gets or sets the result data extracted when the session was accepted, or if not accepted. + /// + /// + /// + /// Implementations should set this in the method + /// (when stopping, i.e., newIsRunning == false) by extracting data from + /// views before they are disposed. + /// + /// + /// indicates the session was stopped without accepting (ESC key, close without action). + /// Non- contains the type-safe result data. + /// + /// + TResult? Result { get; set; } +} diff --git a/Terminal.Gui/App/Toplevel/IToplevelTransitionManager.cs b/Terminal.Gui/App/Runnable/IToplevelTransitionManager.cs similarity index 100% rename from Terminal.Gui/App/Toplevel/IToplevelTransitionManager.cs rename to Terminal.Gui/App/Runnable/IToplevelTransitionManager.cs diff --git a/Terminal.Gui/App/Runnable/RunnableSessionToken.cs b/Terminal.Gui/App/Runnable/RunnableSessionToken.cs new file mode 100644 index 000000000..0f386b635 --- /dev/null +++ b/Terminal.Gui/App/Runnable/RunnableSessionToken.cs @@ -0,0 +1,87 @@ +using System.Collections.Concurrent; + +namespace Terminal.Gui.App; + +/// +/// Represents a running session created by . +/// Wraps an instance and is stored in . +/// +public class RunnableSessionToken : IDisposable +{ + internal RunnableSessionToken (IRunnable runnable) { Runnable = runnable; } + + /// + /// Gets or sets the runnable associated with this session. + /// Set to by when the session completes. + /// + public IRunnable? Runnable { get; internal set; } + + /// + /// Releases all resource used by the object. + /// + /// + /// + /// Call when you are finished using the . + /// + /// + /// method leaves the in an unusable state. After + /// calling + /// , you must release all references to the so the + /// garbage collector can + /// reclaim the memory that the was occupying. + /// + /// + public void Dispose () + { + Dispose (true); + GC.SuppressFinalize (this); + +#if DEBUG_IDISPOSABLE + WasDisposed = true; +#endif + } + + /// + /// Releases all resource used by the object. + /// + /// If set to we are disposing and should dispose held objects. + protected virtual void Dispose (bool disposing) + { + if (Runnable is { } && disposing) + { + // Runnable must be null before disposing + throw new InvalidOperationException ( + "Runnable must be null before calling RunnableSessionToken.Dispose" + ); + } + } + +#if DEBUG_IDISPOSABLE +#pragma warning disable CS0419 // Ambiguous reference in cref attribute + /// + /// Gets whether was called on this RunnableSessionToken or not. + /// For debug purposes to verify objects are being disposed properly. + /// Only valid when DEBUG_IDISPOSABLE is defined. + /// + public bool WasDisposed { get; private set; } + + /// + /// Gets the number of times was called on this object. + /// For debug purposes to verify objects are being disposed properly. + /// Only valid when DEBUG_IDISPOSABLE is defined. + /// + public int DisposedCount { get; private set; } + + /// + /// Gets the list of RunnableSessionToken objects that have been created and not yet disposed. + /// Note, this is a static property and will affect all RunnableSessionToken objects. + /// For debug purposes to verify objects are being disposed properly. + /// Only valid when DEBUG_IDISPOSABLE is defined. + /// + public static ConcurrentBag Instances { get; } = []; + + /// Creates a new RunnableSessionToken object. + public RunnableSessionToken () { Instances.Add (this); } +#pragma warning restore CS0419 // Ambiguous reference in cref attribute +#endif +} diff --git a/Terminal.Gui/App/SessionToken.cs b/Terminal.Gui/App/Runnable/SessionToken.cs similarity index 100% rename from Terminal.Gui/App/SessionToken.cs rename to Terminal.Gui/App/Runnable/SessionToken.cs diff --git a/Terminal.Gui/App/SessionTokenEventArgs.cs b/Terminal.Gui/App/Runnable/SessionTokenEventArgs.cs similarity index 100% rename from Terminal.Gui/App/SessionTokenEventArgs.cs rename to Terminal.Gui/App/Runnable/SessionTokenEventArgs.cs diff --git a/Terminal.Gui/App/Toplevel/ToplevelTransitionManager.cs b/Terminal.Gui/App/Runnable/ToplevelTransitionManager.cs similarity index 100% rename from Terminal.Gui/App/Toplevel/ToplevelTransitionManager.cs rename to Terminal.Gui/App/Runnable/ToplevelTransitionManager.cs diff --git a/Terminal.Gui/ViewBase/Runnable.cs b/Terminal.Gui/ViewBase/Runnable.cs new file mode 100644 index 000000000..eedbc09f1 --- /dev/null +++ b/Terminal.Gui/ViewBase/Runnable.cs @@ -0,0 +1,223 @@ +namespace Terminal.Gui.ViewBase; + +/// +/// Base implementation of for views that can be run as blocking sessions. +/// +/// The type of result data returned when the session completes. +/// +/// +/// Views can derive from this class or implement directly. +/// +/// +/// This class provides default implementations of the interface +/// following the Terminal.Gui Cancellable Work Pattern (CWP). +/// +/// +public class Runnable : View, IRunnable +{ + /// + public TResult? Result { get; set; } + + #region IRunnable Implementation - IsRunning (from base interface) + + /// + public bool IsRunning => App?.RunnableSessionStack?.Any (token => token.Runnable == this) ?? false; + + /// + public bool RaiseIsRunningChanging (bool oldIsRunning, bool newIsRunning) + { + // Clear previous result when starting + if (newIsRunning) + { + Result = default (TResult); + } + + // CWP Phase 1: Virtual method (pre-notification) + if (OnIsRunningChanging (oldIsRunning, newIsRunning)) + { + return true; // Canceled + } + + // CWP Phase 2: Event notification + bool newValue = newIsRunning; + CancelEventArgs args = new (in oldIsRunning, ref newValue); + IsRunningChanging?.Invoke (this, args); + + return args.Cancel; + } + + /// + public event EventHandler>? IsRunningChanging; + + /// + public void RaiseIsRunningChangedEvent (bool newIsRunning) + { + // CWP Phase 3: Post-notification (work already done by Application.Begin/End) + OnIsRunningChanged (newIsRunning); + + EventArgs args = new (newIsRunning); + IsRunningChanged?.Invoke (this, args); + } + + /// + public event EventHandler>? IsRunningChanged; + + /// + /// Called before event. Override to cancel state change or extract + /// . + /// + /// The current value of . + /// The new value of (true = starting, false = stopping). + /// to cancel; to proceed. + /// + /// + /// Default implementation returns (allow change). + /// + /// + /// IMPORTANT: When is (stopping), this is the ideal + /// place + /// to extract from views before the runnable is removed from the stack. + /// At this point, all views are still alive and accessible, and subscribers can inspect the result + /// and optionally cancel the stop. + /// + /// + /// + /// protected override bool OnIsRunningChanging (bool oldIsRunning, bool newIsRunning) + /// { + /// if (!newIsRunning) // Stopping + /// { + /// // Extract result before removal from stack + /// Result = _textField.Text; + /// + /// // Or check if user wants to save first + /// if (HasUnsavedChanges ()) + /// { + /// int result = MessageBox.Query ("Save?", "Save changes?", "Yes", "No", "Cancel"); + /// if (result == 2) return true; // Cancel stopping + /// if (result == 0) Save (); + /// } + /// } + /// + /// return base.OnIsRunningChanging (oldIsRunning, newIsRunning); + /// } + /// + /// + /// + protected virtual bool OnIsRunningChanging (bool oldIsRunning, bool newIsRunning) => false; + + /// + /// Called after has changed. Override for post-state-change logic. + /// + /// The new value of (true = started, false = stopped). + /// + /// Default implementation does nothing. Overrides should call base to ensure extensibility. + /// + protected virtual void OnIsRunningChanged (bool newIsRunning) + { + // Default: no-op + } + + #endregion + + #region IRunnable Implementation - IsModal (from base interface) + + /// + public bool IsModal + { + get + { + if (App is null) + { + return false; + } + + // Check if this runnable is at the top of the RunnableSessionStack + // The top of the stack is the modal runnable + if (App.RunnableSessionStack is { } && App.RunnableSessionStack.TryPeek (out RunnableSessionToken? topToken)) + { + return topToken?.Runnable == this; + } + + // Fallback: Check if this is the TopRunnable (for Toplevel compatibility) + // In Phase 1, TopRunnable is still Toplevel?, so we need to check both cases + if (this is Toplevel tl && App.TopRunnable == tl) + { + return true; + } + + return false; + } + } + + /// + public bool RaiseIsModalChanging (bool oldIsModal, bool newIsModal) + { + // CWP Phase 1: Virtual method (pre-notification) + if (OnIsModalChanging (oldIsModal, newIsModal)) + { + return true; // Canceled + } + + // CWP Phase 2: Event notification + bool newValue = newIsModal; + CancelEventArgs args = new (in oldIsModal, ref newValue); + IsModalChanging?.Invoke (this, args); + + return args.Cancel; + } + + /// + public event EventHandler>? IsModalChanging; + + /// + public void RaiseIsModalChangedEvent (bool newIsModal) + { + // CWP Phase 3: Post-notification (work already done by Application) + OnIsModalChanged (newIsModal); + + EventArgs args = new (newIsModal); + IsModalChanged?.Invoke (this, args); + } + + /// + public event EventHandler>? IsModalChanged; + + /// + /// Called before event. Override to cancel activation/deactivation. + /// + /// The current value of . + /// The new value of (true = becoming modal/top, false = no longer modal). + /// to cancel; to proceed. + /// + /// Default implementation returns (allow change). + /// + protected virtual bool OnIsModalChanging (bool oldIsModal, bool newIsModal) => false; + + /// + /// Called after has changed. Override for post-activation logic. + /// + /// The new value of (true = became modal, false = no longer modal). + /// + /// + /// Default implementation does nothing. Overrides should call base to ensure extensibility. + /// + /// + /// Common uses: setting focus when becoming modal, updating UI state. + /// + /// + protected virtual void OnIsModalChanged (bool newIsModal) + { + // Default: no-op + } + + #endregion + + /// + /// Requests that this runnable session stop. + /// + public virtual void RequestStop () + { + // Use the IRunnable-specific RequestStop if the App supports it + App?.RequestStop (this); + } +} diff --git a/Terminal.Gui/ViewBase/RunnableWrapper.cs b/Terminal.Gui/ViewBase/RunnableWrapper.cs new file mode 100644 index 000000000..5f1d46136 --- /dev/null +++ b/Terminal.Gui/ViewBase/RunnableWrapper.cs @@ -0,0 +1,90 @@ +namespace Terminal.Gui.ViewBase; + +/// +/// Wraps any to make it runnable with a typed result, similar to how +/// wraps . +/// +/// The type of view being wrapped. +/// The type of result data returned when the session completes. +/// +/// +/// This class enables any View to be run as a blocking session with +/// without requiring the View to implement or derive from +/// . +/// +/// +/// Use for a fluent API approach, +/// or to run directly. +/// +/// +/// +/// // Wrap a TextField to make it runnable with string result +/// var textField = new TextField { Width = 40 }; +/// var runnable = new RunnableWrapper<TextField, string> { WrappedView = textField }; +/// +/// // Extract result when stopping +/// runnable.IsRunningChanging += (s, e) => +/// { +/// if (!e.NewValue) // Stopping +/// { +/// runnable.Result = runnable.WrappedView.Text; +/// } +/// }; +/// +/// app.Run(runnable); +/// Console.WriteLine($"User entered: {runnable.Result}"); +/// runnable.Dispose(); +/// +/// +/// +public class RunnableWrapper : Runnable where TView : View +{ + /// + /// Initializes a new instance of . + /// + public RunnableWrapper () + { + // Make the wrapper automatically size to fit the wrapped view + Width = Dim.Fill (); + Height = Dim.Fill (); + } + + private TView? _wrappedView; + + /// + /// Gets or sets the wrapped view that is being made runnable. + /// + /// + /// + /// This property must be set before the wrapper is initialized. + /// Access this property to interact with the original view, extract its state, + /// or configure result extraction logic. + /// + /// + /// Thrown if the property is set after initialization. + public required TView WrappedView + { + get => _wrappedView ?? throw new InvalidOperationException ("WrappedView must be set before use."); + init + { + if (IsInitialized) + { + throw new InvalidOperationException ("WrappedView cannot be changed after initialization."); + } + + _wrappedView = value; + } + } + + /// + public override void EndInit () + { + base.EndInit (); + + // Add the wrapped view as a subview after initialization + if (_wrappedView is { }) + { + Add (_wrappedView); + } + } +} diff --git a/Terminal.Gui/ViewBase/ViewRunnableExtensions.cs b/Terminal.Gui/ViewBase/ViewRunnableExtensions.cs new file mode 100644 index 000000000..7b12bb055 --- /dev/null +++ b/Terminal.Gui/ViewBase/ViewRunnableExtensions.cs @@ -0,0 +1,126 @@ +namespace Terminal.Gui.ViewBase; + +/// +/// Extension methods for making any runnable with typed results. +/// +/// +/// These extensions provide a fluent API for wrapping views in , +/// enabling any View to be run as a blocking session without implementing . +/// +public static class ViewRunnableExtensions +{ + /// + /// Converts any View into a runnable with typed result extraction. + /// + /// The type of view to make runnable. + /// The type of result data to extract. + /// The view to wrap. Cannot be null. + /// + /// Function that extracts the result from the view when stopping. + /// Called automatically when the runnable session ends. + /// + /// A that wraps the view. + /// Thrown if or is null. + /// + /// + /// This method wraps the view in a and automatically + /// subscribes to to extract the result when the session stops. + /// + /// + /// The result is extracted before the view is disposed, ensuring all data is still accessible. + /// + /// + /// + /// + /// // Make a TextField runnable with string result + /// var runnable = new TextField { Width = 40 } + /// .AsRunnable(tf => tf.Text); + /// + /// app.Run(runnable); + /// Console.WriteLine($"User entered: {runnable.Result}"); + /// runnable.Dispose(); + /// + /// // Make a ColorPicker runnable with Color? result + /// var colorRunnable = new ColorPicker() + /// .AsRunnable(cp => cp.SelectedColor); + /// + /// app.Run(colorRunnable); + /// Console.WriteLine($"Selected: {colorRunnable.Result}"); + /// colorRunnable.Dispose(); + /// + /// // Make a FlagSelector runnable with enum result + /// var flagsRunnable = new FlagSelector<SelectorStyles>() + /// .AsRunnable(fs => fs.Value); + /// + /// app.Run(flagsRunnable); + /// Console.WriteLine($"Selected styles: {flagsRunnable.Result}"); + /// flagsRunnable.Dispose(); + /// + /// + public static RunnableWrapper AsRunnable ( + this TView view, + Func resultExtractor) + where TView : View + { + if (view is null) + { + throw new ArgumentNullException (nameof (view)); + } + + if (resultExtractor is null) + { + throw new ArgumentNullException (nameof (resultExtractor)); + } + + var wrapper = new RunnableWrapper { WrappedView = view }; + + // Subscribe to IsRunningChanging to extract result when stopping + wrapper.IsRunningChanging += (s, e) => + { + if (!e.NewValue) // Stopping + { + wrapper.Result = resultExtractor (view); + } + }; + + return wrapper; + } + + /// + /// Converts any View into a runnable without result extraction. + /// + /// The type of view to make runnable. + /// The view to wrap. Cannot be null. + /// A that wraps the view. + /// Thrown if is null. + /// + /// + /// Use this overload when you don't need to extract a typed result, but still want to + /// run the view as a blocking session. The wrapped view can still be accessed via + /// after running. + /// + /// + /// + /// + /// // Make a view runnable without result extraction + /// var colorPicker = new ColorPicker(); + /// var runnable = colorPicker.AsRunnable(); + /// + /// app.Run(runnable); + /// + /// // Access the wrapped view directly to get the result + /// Console.WriteLine($"Selected: {runnable.WrappedView.SelectedColor}"); + /// runnable.Dispose(); + /// + /// + public static RunnableWrapper AsRunnable (this TView view) + where TView : View + { + if (view is null) + { + throw new ArgumentNullException (nameof (view)); + } + + return new RunnableWrapper { WrappedView = view }; + } +} diff --git a/Terminal.sln b/Terminal.sln index 050122e35..b2fb285cd 100644 --- a/Terminal.sln +++ b/Terminal.sln @@ -122,6 +122,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{1A3C Tests\UnitTests\runsettings.xml = Tests\UnitTests\runsettings.xml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FluentExample", "Examples\FluentExample\FluentExample.csproj", "{8C05292F-86C9-C29A-635B-A4DFC5955D1C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RunnableWrapperExample", "Examples\RunnableWrapperExample\RunnableWrapperExample.csproj", "{26FDEE3C-9D1F-79A6-F48F-D0944C7F09F8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -196,6 +200,14 @@ Global {8C643A64-2A77-4432-987A-2E72BD9708E3}.Debug|Any CPU.Build.0 = Debug|Any CPU {8C643A64-2A77-4432-987A-2E72BD9708E3}.Release|Any CPU.ActiveCfg = Release|Any CPU {8C643A64-2A77-4432-987A-2E72BD9708E3}.Release|Any CPU.Build.0 = Release|Any CPU + {8C05292F-86C9-C29A-635B-A4DFC5955D1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C05292F-86C9-C29A-635B-A4DFC5955D1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C05292F-86C9-C29A-635B-A4DFC5955D1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C05292F-86C9-C29A-635B-A4DFC5955D1C}.Release|Any CPU.Build.0 = Release|Any CPU + {26FDEE3C-9D1F-79A6-F48F-D0944C7F09F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index ef25662a4..e6cac2cdd 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -417,12 +417,14 @@ True True True + True True True True True True True + True True True True diff --git a/Tests/UnitTests/Application/ApplicationImplBeginEndTests.cs b/Tests/UnitTests/Application/ApplicationImplBeginEndTests.cs index ad48cf802..956964738 100644 --- a/Tests/UnitTests/Application/ApplicationImplBeginEndTests.cs +++ b/Tests/UnitTests/Application/ApplicationImplBeginEndTests.cs @@ -8,11 +8,9 @@ namespace UnitTests.ApplicationTests; /// These tests ensure the fragile state management logic is robust and catches regressions. /// Tests work directly with ApplicationImpl instances to avoid global Application state issues. /// -public class ApplicationImplBeginEndTests +public class ApplicationImplBeginEndTests (ITestOutputHelper output) { - private readonly ITestOutputHelper _output; - - public ApplicationImplBeginEndTests (ITestOutputHelper output) { _output = output; } + private readonly ITestOutputHelper _output = output; private IApplication NewApplicationImpl () { @@ -28,7 +26,7 @@ public class ApplicationImplBeginEndTests try { - Assert.Throws (() => app.Begin (null!)); + Assert.Throws (() => app.Begin ((Toplevel)null!)); } finally { @@ -69,8 +67,8 @@ public class ApplicationImplBeginEndTests try { - toplevel1 = new() { Id = "1" }; - toplevel2 = new() { Id = "2" }; + toplevel1 = new () { Id = "1" }; + toplevel2 = new () { Id = "2" }; app.Begin (toplevel1); Assert.Single (app.SessionStack); @@ -135,7 +133,7 @@ public class ApplicationImplBeginEndTests try { - Assert.Throws (() => app.End (null!)); + Assert.Throws (() => app.End ((SessionToken)null!)); } finally { @@ -152,8 +150,8 @@ public class ApplicationImplBeginEndTests try { - toplevel1 = new() { Id = "1" }; - toplevel2 = new() { Id = "2" }; + toplevel1 = new () { Id = "1" }; + toplevel2 = new () { Id = "2" }; SessionToken token1 = app.Begin (toplevel1); SessionToken token2 = app.Begin (toplevel2); @@ -186,8 +184,8 @@ public class ApplicationImplBeginEndTests try { - toplevel1 = new() { Id = "1" }; - toplevel2 = new() { Id = "2" }; + toplevel1 = new () { Id = "1" }; + toplevel2 = new () { Id = "2" }; SessionToken token1 = app.Begin (toplevel1); SessionToken token2 = app.Begin (toplevel2); @@ -220,9 +218,9 @@ public class ApplicationImplBeginEndTests try { - toplevel1 = new() { Id = "1" }; - toplevel2 = new() { Id = "2" }; - toplevel3 = new() { Id = "3" }; + toplevel1 = new () { Id = "1" }; + toplevel2 = new () { Id = "2" }; + toplevel3 = new () { Id = "3" }; SessionToken token1 = app.Begin (toplevel1); SessionToken token2 = app.Begin (toplevel2); @@ -351,8 +349,8 @@ public class ApplicationImplBeginEndTests try { - toplevel1 = new() { Id = "1" }; - toplevel2 = new() { Id = "2" }; + toplevel1 = new () { Id = "1" }; + toplevel2 = new () { Id = "2" }; app.Begin (toplevel1); app.Begin (toplevel2); @@ -385,8 +383,8 @@ public class ApplicationImplBeginEndTests try { - toplevel1 = new() { Id = "1", Running = true }; - toplevel2 = new() { Id = "2", Running = true }; + toplevel1 = new () { Id = "1", Running = true }; + toplevel2 = new () { Id = "2", Running = true }; app.Begin (toplevel1); app.Begin (toplevel2); @@ -418,8 +416,8 @@ public class ApplicationImplBeginEndTests try { - toplevel1 = new() { Id = "1" }; - toplevel2 = new() { Id = "2" }; + toplevel1 = new () { Id = "1" }; + toplevel2 = new () { Id = "2" }; var toplevel1Deactivated = false; var toplevel2Activated = false; @@ -450,7 +448,7 @@ public class ApplicationImplBeginEndTests try { - toplevel = new() { Id = "test-id" }; + toplevel = new () { Id = "test-id" }; app.Begin (toplevel); Assert.Single (app.SessionStack); diff --git a/Tests/UnitTests/Application/ApplicationPopoverTests.cs b/Tests/UnitTests/Application/ApplicationPopoverTests.cs index 778e681a9..4d3db123a 100644 --- a/Tests/UnitTests/Application/ApplicationPopoverTests.cs +++ b/Tests/UnitTests/Application/ApplicationPopoverTests.cs @@ -219,8 +219,8 @@ public class ApplicationPopoverTests { // Arrange Application.Init ("fake"); - Application.TopRunnable = new() { Id = "initialTop" }; - PopoverTestClass? popover = new () { }; + Application.TopRunnable = new () { Id = "initialTop" }; + PopoverTestClass? popover = new (); var keyDownEvents = 0; popover.KeyDown += (s, e) => @@ -234,7 +234,7 @@ public class ApplicationPopoverTests // Act Application.RaiseKeyDownEvent (Key.A); // Goes to initialTop - Application.TopRunnable = new() { Id = "secondaryTop" }; + Application.TopRunnable = new () { Id = "secondaryTop" }; Application.RaiseKeyDownEvent (Key.A); // Goes to secondaryTop // Test diff --git a/Tests/UnitTests/TestsAllViews.cs b/Tests/UnitTests/TestsAllViews.cs index 7428e26c0..583f94457 100644 --- a/Tests/UnitTests/TestsAllViews.cs +++ b/Tests/UnitTests/TestsAllViews.cs @@ -64,7 +64,15 @@ public class TestsAllViews : FakeDriverBase // use or the original type if applicable foreach (Type arg in type.GetGenericArguments ()) { - if (arg.IsValueType && Nullable.GetUnderlyingType (arg) == null) + // Check if this type parameter has constraints that object can't satisfy + Type [] constraints = arg.GetGenericParameterConstraints (); + + // If there's a View constraint, use View instead of object + if (constraints.Any (c => c == typeof (View) || c.IsSubclassOf (typeof (View)))) + { + typeArguments.Add (typeof (View)); + } + else if (arg.IsValueType && Nullable.GetUnderlyingType (arg) == null) { typeArguments.Add (arg); } @@ -85,6 +93,14 @@ public class TestsAllViews : FakeDriverBase return null; } + // Check if the type has required properties that can't be satisfied by Activator.CreateInstance + // This handles cases like RunnableWrapper which has a required WrappedView property + if (HasRequiredProperties (type)) + { + Logging.Warning ($"Cannot create an instance of {type} because it has required properties that must be set."); + return null; + } + Assert.IsType (type, (View)Activator.CreateInstance (type)!); } else @@ -139,6 +155,16 @@ public class TestsAllViews : FakeDriverBase return viewType; } + /// + /// Checks if a type has required properties (C# 11 feature). + /// + private static bool HasRequiredProperties (Type type) + { + // Check all public instance properties for the RequiredMemberAttribute + return type.GetProperties (BindingFlags.Public | BindingFlags.Instance) + .Any (p => p.GetCustomAttributes (typeof (System.Runtime.CompilerServices.RequiredMemberAttribute), true).Any ()); + } + private static void AddArguments (Type paramType, List pTypes) { if (paramType == typeof (Rectangle)) diff --git a/Tests/UnitTestsParallelizable/Application/Runnable/RunnableEdgeCasesTests.cs b/Tests/UnitTestsParallelizable/Application/Runnable/RunnableEdgeCasesTests.cs new file mode 100644 index 000000000..67e568984 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Application/Runnable/RunnableEdgeCasesTests.cs @@ -0,0 +1,327 @@ +using Xunit.Abstractions; + +namespace UnitTests_Parallelizable.ApplicationTests.RunnableTests; + +/// +/// Tests for edge cases and error conditions in IRunnable implementation. +/// +public class RunnableEdgeCasesTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + [Fact] + public void RunnableSessionToken_CannotDisposeWithRunnableSet () + { + // Arrange + Runnable runnable = new (); + RunnableSessionToken token = new (runnable); + + // Act & Assert + var ex = Assert.Throws (() => token.Dispose ()); + Assert.Contains ("Runnable must be null", ex.Message); + } + + [Fact] + public void RunnableSessionToken_CanDisposeAfterClearingRunnable () + { + // Arrange + Runnable runnable = new (); + RunnableSessionToken token = new (runnable); + token.Runnable = null; + + // Act & Assert - Should not throw + token.Dispose (); + } + + [Fact] + public void Runnable_MultipleEventSubscribers_AllInvoked () + { + // Arrange + Runnable runnable = new (); + var subscriber1Called = false; + var subscriber2Called = false; + var subscriber3Called = false; + + runnable.IsRunningChanging += (s, e) => subscriber1Called = true; + runnable.IsRunningChanging += (s, e) => subscriber2Called = true; + runnable.IsRunningChanging += (s, e) => subscriber3Called = true; + + // Act + runnable.RaiseIsRunningChanging (false, true); + + // Assert + Assert.True (subscriber1Called); + Assert.True (subscriber2Called); + Assert.True (subscriber3Called); + } + + [Fact] + public void Runnable_EventSubscriber_CanCancelAfterOthers () + { + // Arrange + Runnable runnable = new (); + var subscriber1Called = false; + var subscriber2Called = false; + + runnable.IsRunningChanging += (s, e) => subscriber1Called = true; + + runnable.IsRunningChanging += (s, e) => + { + subscriber2Called = true; + e.Cancel = true; // Second subscriber cancels + }; + + // Act + bool canceled = runnable.RaiseIsRunningChanging (false, true); + + // Assert + Assert.True (subscriber1Called); + Assert.True (subscriber2Called); + Assert.True (canceled); + } + + [Fact] + public void Runnable_Result_CanBeSetMultipleTimes () + { + // Arrange + Runnable runnable = new (); + + // Act + runnable.Result = 1; + runnable.Result = 2; + runnable.Result = 3; + + // Assert + Assert.Equal (3, runnable.Result); + } + + [Fact] + public void Runnable_Result_ClearedOnMultipleStarts () + { + // Arrange + Runnable runnable = new () { Result = 42 }; + + // Act & Assert - First start + runnable.RaiseIsRunningChanging (false, true); + Assert.Equal (0, runnable.Result); + + // Set result again + runnable.Result = 99; + Assert.Equal (99, runnable.Result); + + // Second start should clear again + runnable.RaiseIsRunningChanging (false, true); + Assert.Equal (0, runnable.Result); + } + + [Fact] + public void Runnable_NullableResult_DefaultsToNull () + { + // Arrange & Act + Runnable runnable = new (); + + // Assert + Assert.Null (runnable.Result); + } + + [Fact] + public void Runnable_NullableResult_CanBeExplicitlyNull () + { + // Arrange + Runnable runnable = new () { Result = "test" }; + + // Act + runnable.Result = null; + + // Assert + Assert.Null (runnable.Result); + } + + [Fact] + public void Runnable_ComplexType_Result () + { + // Arrange + Runnable runnable = new (); + ComplexResult result = new () { Value = 42, Text = "test" }; + + // Act + runnable.Result = result; + + // Assert + Assert.NotNull (runnable.Result); + Assert.Equal (42, runnable.Result.Value); + Assert.Equal ("test", runnable.Result.Text); + } + + [Fact] + public void Runnable_IsRunning_WithNoApp () + { + // Arrange + Runnable runnable = new (); + + // Don't set App property + + // Act & Assert + Assert.False (runnable.IsRunning); + } + + [Fact] + public void Runnable_IsModal_WithNoApp () + { + // Arrange + Runnable runnable = new (); + + // Don't set App property + + // Act & Assert + Assert.False (runnable.IsModal); + } + + [Fact] + public void Runnable_VirtualMethods_CanBeOverridden () + { + // Arrange + OverriddenRunnable runnable = new (); + + // Act + bool canceledRunning = runnable.RaiseIsRunningChanging (false, true); + runnable.RaiseIsRunningChangedEvent (true); + bool canceledModal = runnable.RaiseIsModalChanging (false, true); + runnable.RaiseIsModalChangedEvent (true); + + // Assert + Assert.True (runnable.OnIsRunningChangingCalled); + Assert.True (runnable.OnIsRunningChangedCalled); + Assert.True (runnable.OnIsModalChangingCalled); + Assert.True (runnable.OnIsModalChangedCalled); + } + + [Fact] + public void Runnable_RequestStop_WithNoApp () + { + // Arrange + Runnable runnable = new (); + + // Don't set App property + + // Act & Assert - Should not throw + runnable.RequestStop (); + } + + [Fact] + public void RunnableSessionToken_Constructor_RequiresRunnable () + { + // This is implicitly tested by the constructor signature, + // but let's verify it creates with non-null runnable + + // Arrange + Runnable runnable = new (); + + // Act + RunnableSessionToken token = new (runnable); + + // Assert + Assert.NotNull (token.Runnable); + } + + [Fact] + public void Runnable_EventArgs_PreservesValues () + { + // Arrange + Runnable runnable = new (); + bool? capturedOldValue = null; + bool? capturedNewValue = null; + + runnable.IsRunningChanging += (s, e) => + { + capturedOldValue = e.CurrentValue; + capturedNewValue = e.NewValue; + }; + + // Act + runnable.RaiseIsRunningChanging (false, true); + + // Assert + Assert.NotNull (capturedOldValue); + Assert.NotNull (capturedNewValue); + Assert.False (capturedOldValue.Value); + Assert.True (capturedNewValue.Value); + } + + [Fact] + public void Runnable_IsModalChanged_EventArgs_PreservesValue () + { + // Arrange + Runnable runnable = new (); + bool? capturedValue = null; + + runnable.IsModalChanged += (s, e) => { capturedValue = e.Value; }; + + // Act + runnable.RaiseIsModalChangedEvent (true); + + // Assert + Assert.NotNull (capturedValue); + Assert.True (capturedValue.Value); + } + + [Fact] + public void Runnable_DifferentGenericTypes_Independent () + { + // Arrange & Act + Runnable intRunnable = new () { Result = 42 }; + Runnable stringRunnable = new () { Result = "test" }; + Runnable boolRunnable = new () { Result = true }; + + // Assert + Assert.Equal (42, intRunnable.Result); + Assert.Equal ("test", stringRunnable.Result); + Assert.True (boolRunnable.Result); + } + + /// + /// Complex result type for testing. + /// + private class ComplexResult + { + public int Value { get; set; } + public string? Text { get; set; } + } + + /// + /// Runnable that tracks virtual method calls. + /// + private class OverriddenRunnable : Runnable + { + public bool OnIsRunningChangingCalled { get; private set; } + public bool OnIsRunningChangedCalled { get; private set; } + public bool OnIsModalChangingCalled { get; private set; } + public bool OnIsModalChangedCalled { get; private set; } + + protected override bool OnIsRunningChanging (bool oldIsRunning, bool newIsRunning) + { + OnIsRunningChangingCalled = true; + + return base.OnIsRunningChanging (oldIsRunning, newIsRunning); + } + + protected override void OnIsRunningChanged (bool newIsRunning) + { + OnIsRunningChangedCalled = true; + base.OnIsRunningChanged (newIsRunning); + } + + protected override bool OnIsModalChanging (bool oldIsModal, bool newIsModal) + { + OnIsModalChangingCalled = true; + + return base.OnIsModalChanging (oldIsModal, newIsModal); + } + + protected override void OnIsModalChanged (bool newIsModal) + { + OnIsModalChangedCalled = true; + base.OnIsModalChanged (newIsModal); + } + } +} diff --git a/Tests/UnitTestsParallelizable/Application/Runnable/RunnableIntegrationTests.cs b/Tests/UnitTestsParallelizable/Application/Runnable/RunnableIntegrationTests.cs new file mode 100644 index 000000000..d9ef7b6a5 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Application/Runnable/RunnableIntegrationTests.cs @@ -0,0 +1,543 @@ +using Xunit.Abstractions; + +namespace UnitTests_Parallelizable.ApplicationTests.RunnableTests; + +/// +/// Integration tests for IApplication's IRunnable support. +/// Tests the full lifecycle of IRunnable instances through Application methods. +/// +public class ApplicationRunnableIntegrationTests (ITestOutputHelper output) : IDisposable +{ + private readonly ITestOutputHelper _output = output; + private IApplication? _app; + + private IApplication GetApp () + { + if (_app is null) + { + _app = Application.Create (); + _app.Init ("fake"); + } + + return _app; + } + + public void Dispose () + { + _app?.Shutdown (); + _app = null; + } + + [Fact] + public void Begin_AddsRunnableToStack () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable = new (); + int stackCountBefore = app.RunnableSessionStack?.Count ?? 0; + + // Act + RunnableSessionToken token = app.Begin (runnable); + + // Assert + Assert.NotNull (token); + Assert.NotNull (token.Runnable); + Assert.Same (runnable, token.Runnable); + Assert.Equal (stackCountBefore + 1, app.RunnableSessionStack?.Count ?? 0); + + // Cleanup + app.End (token); + } + + [Fact] + public void Begin_ThrowsOnNullRunnable () + { + // Arrange + IApplication app = GetApp (); + + // Act & Assert + Assert.Throws (() => app.Begin ((IRunnable)null!)); + } + + [Fact] + public void Begin_RaisesIsRunningChangingEvent () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable = new (); + var isRunningChangingRaised = false; + bool? oldValue = null; + bool? newValue = null; + + runnable.IsRunningChanging += (s, e) => + { + isRunningChangingRaised = true; + oldValue = e.CurrentValue; + newValue = e.NewValue; + }; + + // Act + RunnableSessionToken token = app.Begin (runnable); + + // Assert + Assert.True (isRunningChangingRaised); + Assert.False (oldValue); + Assert.True (newValue); + + // Cleanup + app.End (token); + } + + [Fact] + public void Begin_RaisesIsRunningChangedEvent () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable = new (); + var isRunningChangedRaised = false; + bool? receivedValue = null; + + runnable.IsRunningChanged += (s, e) => + { + isRunningChangedRaised = true; + receivedValue = e.Value; + }; + + // Act + RunnableSessionToken token = app.Begin (runnable); + + // Assert + Assert.True (isRunningChangedRaised); + Assert.True (receivedValue); + + // Cleanup + app.End (token); + } + + [Fact] + public void Begin_RaisesIsModalChangingEvent () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable = new (); + var isModalChangingRaised = false; + bool? oldValue = null; + bool? newValue = null; + + runnable.IsModalChanging += (s, e) => + { + isModalChangingRaised = true; + oldValue = e.CurrentValue; + newValue = e.NewValue; + }; + + // Act + RunnableSessionToken token = app.Begin (runnable); + + // Assert + Assert.True (isModalChangingRaised); + Assert.False (oldValue); + Assert.True (newValue); + + // Cleanup + app.End (token); + } + + [Fact] + public void Begin_RaisesIsModalChangedEvent () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable = new (); + var isModalChangedRaised = false; + bool? receivedValue = null; + + runnable.IsModalChanged += (s, e) => + { + isModalChangedRaised = true; + receivedValue = e.Value; + }; + + // Act + RunnableSessionToken token = app.Begin (runnable); + + // Assert + Assert.True (isModalChangedRaised); + Assert.True (receivedValue); + + // Cleanup + app.End (token); + } + + [Fact] + public void Begin_SetsIsRunningToTrue () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable = new (); + + // Act + RunnableSessionToken token = app.Begin (runnable); + + // Assert + Assert.True (runnable.IsRunning); + + // Cleanup + app.End (token); + } + + [Fact] + public void Begin_SetsIsModalToTrue () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable = new (); + + // Act + RunnableSessionToken token = app.Begin (runnable); + + // Assert + Assert.True (runnable.IsModal); + + // Cleanup + app.End (token); + } + + [Fact] + public void End_RemovesRunnableFromStack () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable = new (); + RunnableSessionToken token = app.Begin (runnable); + int stackCountBefore = app.RunnableSessionStack?.Count ?? 0; + + // Act + app.End (token); + + // Assert + Assert.Equal (stackCountBefore - 1, app.RunnableSessionStack?.Count ?? 0); + } + + [Fact] + public void End_ThrowsOnNullToken () + { + // Arrange + IApplication app = GetApp (); + + // Act & Assert + Assert.Throws (() => app.End ((RunnableSessionToken)null!)); + } + + [Fact] + public void End_RaisesIsRunningChangingEvent () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable = new (); + RunnableSessionToken token = app.Begin (runnable); + var isRunningChangingRaised = false; + bool? oldValue = null; + bool? newValue = null; + + runnable.IsRunningChanging += (s, e) => + { + isRunningChangingRaised = true; + oldValue = e.CurrentValue; + newValue = e.NewValue; + }; + + // Act + app.End (token); + + // Assert + Assert.True (isRunningChangingRaised); + Assert.True (oldValue); + Assert.False (newValue); + } + + [Fact] + public void End_RaisesIsRunningChangedEvent () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable = new (); + RunnableSessionToken token = app.Begin (runnable); + var isRunningChangedRaised = false; + bool? receivedValue = null; + + runnable.IsRunningChanged += (s, e) => + { + isRunningChangedRaised = true; + receivedValue = e.Value; + }; + + // Act + app.End (token); + + // Assert + Assert.True (isRunningChangedRaised); + Assert.False (receivedValue); + } + + [Fact] + public void End_SetsIsRunningToFalse () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable = new (); + RunnableSessionToken token = app.Begin (runnable); + + // Act + app.End (token); + + // Assert + Assert.False (runnable.IsRunning); + } + + [Fact] + public void End_SetsIsModalToFalse () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable = new (); + RunnableSessionToken token = app.Begin (runnable); + + // Act + app.End (token); + + // Assert + Assert.False (runnable.IsModal); + } + + [Fact] + public void End_ClearsTokenRunnable () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable = new (); + RunnableSessionToken token = app.Begin (runnable); + + // Act + app.End (token); + + // Assert + Assert.Null (token.Runnable); + } + + [Fact] + public void NestedBegin_MaintainsStackOrder () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable1 = new () { Id = "1" }; + Runnable runnable2 = new () { Id = "2" }; + + // Act + RunnableSessionToken token1 = app.Begin (runnable1); + RunnableSessionToken token2 = app.Begin (runnable2); + + // Assert - runnable2 should be on top + Assert.True (runnable2.IsModal); + Assert.False (runnable1.IsModal); + Assert.True (runnable1.IsRunning); // Still running, just not modal + Assert.True (runnable2.IsRunning); + + // Cleanup + app.End (token2); + app.End (token1); + } + + [Fact] + public void NestedEnd_RestoresPreviousModal () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable1 = new () { Id = "1" }; + Runnable runnable2 = new () { Id = "2" }; + RunnableSessionToken token1 = app.Begin (runnable1); + RunnableSessionToken token2 = app.Begin (runnable2); + + // Act - End the top runnable + app.End (token2); + + // Assert - runnable1 should become modal again + Assert.True (runnable1.IsModal); + Assert.False (runnable2.IsModal); + Assert.True (runnable1.IsRunning); + Assert.False (runnable2.IsRunning); + + // Cleanup + app.End (token1); + } + + [Fact] + public void RequestStop_WithIRunnable_WorksCorrectly () + { + // Arrange + IApplication app = GetApp (); + StoppableRunnable runnable = new (); + RunnableSessionToken token = app.Begin (runnable); + + // Act + app.RequestStop (runnable); + + // Assert - RequestStop should trigger End eventually + // For now, just verify it doesn't throw + Assert.NotNull (runnable); + + // Cleanup + app.End (token); + } + + [Fact] + public void RequestStop_WithNull_UsesTopRunnable () + { + // Arrange + IApplication app = GetApp (); + StoppableRunnable runnable = new (); + RunnableSessionToken token = app.Begin (runnable); + + // Act + app.RequestStop ((IRunnable?)null); + + // Assert - Should not throw + Assert.NotNull (runnable); + + // Cleanup + app.End (token); + } + + [Fact (Skip = "Run methods with main loop are not suitable for parallel tests - use non-parallel UnitTests instead")] + public void RunGeneric_CreatesAndReturnsRunnable () + { + // Arrange + IApplication app = GetApp (); + app.StopAfterFirstIteration = true; + + // Act - With fluent API, Run() returns IApplication for chaining + IApplication result = app.Run (); + + // Assert + Assert.NotNull (result); + Assert.Same (app, result); // Fluent API returns this + + // Note: Run blocks until stopped, but StopAfterFirstIteration makes it return immediately + // The runnable is automatically disposed by Shutdown() + } + + [Fact (Skip = "Run methods with main loop are not suitable for parallel tests - use non-parallel UnitTests instead")] + public void RunGeneric_ThrowsIfNotInitialized () + { + // Arrange + IApplication app = Application.Create (); + + // Don't call Init + + // Act & Assert + Assert.Throws (() => app.Run ()); + + // Cleanup + app.Shutdown (); + } + + [Fact] + public void Begin_CanBeCanceled_ByIsRunningChanging () + { + // Arrange + IApplication app = GetApp (); + CancelableRunnable runnable = new () { CancelStart = true }; + + // Act + RunnableSessionToken token = app.Begin (runnable); + + // Assert - Should not be added to stack if canceled + Assert.False (runnable.IsRunning); + + // Token is still created but runnable not added to stack + Assert.NotNull (token); + } + + [Fact] + public void End_CanBeCanceled_ByIsRunningChanging () + { + // Arrange + IApplication app = GetApp (); + CancelableRunnable runnable = new () { CancelStop = true }; + RunnableSessionToken token = app.Begin (runnable); + runnable.CancelStop = true; // Enable cancellation + + // Act + app.End (token); + + // Assert - Should still be running if canceled + Assert.True (runnable.IsRunning); + + // Force end by disabling cancellation + runnable.CancelStop = false; + app.End (token); + } + + [Fact] + public void MultipleRunnables_IndependentResults () + { + // Arrange + IApplication app = GetApp (); + Runnable runnable1 = new (); + Runnable runnable2 = new (); + + // Act + runnable1.Result = 42; + runnable2.Result = "test"; + + // Assert + Assert.Equal (42, runnable1.Result); + Assert.Equal ("test", runnable2.Result); + } + + /// + /// Test runnable that can be stopped. + /// + private class StoppableRunnable : Runnable + { + public bool WasStopRequested { get; private set; } + + public override void RequestStop () + { + WasStopRequested = true; + base.RequestStop (); + } + } + + /// + /// Test runnable for generic Run tests. + /// + private class TestRunnable : Runnable + { + public TestRunnable () { Id = "TestRunnable"; } + } + + /// + /// Test runnable that can cancel lifecycle changes. + /// + private class CancelableRunnable : Runnable + { + public bool CancelStart { get; set; } + public bool CancelStop { get; set; } + + protected override bool OnIsRunningChanging (bool oldIsRunning, bool newIsRunning) + { + if (newIsRunning && CancelStart) + { + return true; // Cancel starting + } + + if (!newIsRunning && CancelStop) + { + return true; // Cancel stopping + } + + return base.OnIsRunningChanging (oldIsRunning, newIsRunning); + } + } +} diff --git a/Tests/UnitTestsParallelizable/Application/Runnable/RunnableLifecycleTests.cs b/Tests/UnitTestsParallelizable/Application/Runnable/RunnableLifecycleTests.cs new file mode 100644 index 000000000..f84bb85d1 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Application/Runnable/RunnableLifecycleTests.cs @@ -0,0 +1,156 @@ +using Xunit.Abstractions; + +namespace UnitTests_Parallelizable.ApplicationTests.RunnableTests; + +/// +/// Tests for IRunnable lifecycle behavior. +/// +public class RunnableLifecycleTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + [Fact] + public void Runnable_OnIsRunningChanging_CanExtractResult () + { + // Arrange + ResultExtractingRunnable runnable = new (); + runnable.TestValue = "extracted"; + + // Act + bool canceled = runnable.RaiseIsRunningChanging (true, false); // Stopping + + // Assert + Assert.False (canceled); + Assert.Equal ("extracted", runnable.Result); + } + + [Fact] + public void Runnable_OnIsRunningChanging_ClearsResultWhenStarting () + { + // Arrange + ResultExtractingRunnable runnable = new () { Result = "previous" }; + + // Act + bool canceled = runnable.RaiseIsRunningChanging (false, true); // Starting + + // Assert + Assert.False (canceled); + Assert.Null (runnable.Result); // Result should be cleared + } + + [Fact] + public void Runnable_CanCancelStoppingWithUnsavedChanges () + { + // Arrange + UnsavedChangesRunnable runnable = new () { HasUnsavedChanges = true }; + + // Act + bool canceled = runnable.RaiseIsRunningChanging (true, false); // Stopping + + // Assert + Assert.True (canceled); // Should be canceled + } + + [Fact] + public void Runnable_AllowsStoppingWithoutUnsavedChanges () + { + // Arrange + UnsavedChangesRunnable runnable = new () { HasUnsavedChanges = false }; + + // Act + bool canceled = runnable.RaiseIsRunningChanging (true, false); // Stopping + + // Assert + Assert.False (canceled); // Should not be canceled + } + + [Fact] + public void Runnable_OnIsRunningChanged_CalledAfterStateChange () + { + // Arrange + TrackedRunnable runnable = new (); + + // Act + runnable.RaiseIsRunningChangedEvent (true); + + // Assert + Assert.True (runnable.OnIsRunningChangedCalled); + Assert.True (runnable.LastIsRunningValue); + } + + [Fact] + public void Runnable_OnIsModalChanged_CalledAfterStateChange () + { + // Arrange + TrackedRunnable runnable = new (); + + // Act + runnable.RaiseIsModalChangedEvent (true); + + // Assert + Assert.True (runnable.OnIsModalChangedCalled); + Assert.True (runnable.LastIsModalValue); + } + + /// + /// Test runnable that extracts result in OnIsRunningChanging. + /// + private class ResultExtractingRunnable : Runnable + { + public string? TestValue { get; set; } + + protected override bool OnIsRunningChanging (bool oldIsRunning, bool newIsRunning) + { + if (!newIsRunning) // Stopping + { + // Extract result before removal from stack + Result = TestValue; + } + + return base.OnIsRunningChanging (oldIsRunning, newIsRunning); + } + } + + /// + /// Test runnable that can prevent stopping with unsaved changes. + /// + private class UnsavedChangesRunnable : Runnable + { + public bool HasUnsavedChanges { get; set; } + + protected override bool OnIsRunningChanging (bool oldIsRunning, bool newIsRunning) + { + if (!newIsRunning && HasUnsavedChanges) // Stopping with unsaved changes + { + return true; // Cancel stopping + } + + return base.OnIsRunningChanging (oldIsRunning, newIsRunning); + } + } + + /// + /// Test runnable that tracks lifecycle method calls. + /// + private class TrackedRunnable : Runnable + { + public bool OnIsRunningChangedCalled { get; private set; } + public bool LastIsRunningValue { get; private set; } + public bool OnIsModalChangedCalled { get; private set; } + public bool LastIsModalValue { get; private set; } + + protected override void OnIsRunningChanged (bool newIsRunning) + { + OnIsRunningChangedCalled = true; + LastIsRunningValue = newIsRunning; + base.OnIsRunningChanged (newIsRunning); + } + + protected override void OnIsModalChanged (bool newIsModal) + { + OnIsModalChangedCalled = true; + LastIsModalValue = newIsModal; + base.OnIsModalChanged (newIsModal); + } + } +} diff --git a/Tests/UnitTestsParallelizable/Application/Runnable/RunnableSessionTokenTests.cs b/Tests/UnitTestsParallelizable/Application/Runnable/RunnableSessionTokenTests.cs new file mode 100644 index 000000000..82439cd5d --- /dev/null +++ b/Tests/UnitTestsParallelizable/Application/Runnable/RunnableSessionTokenTests.cs @@ -0,0 +1,62 @@ +using Xunit.Abstractions; + +namespace UnitTests_Parallelizable.ApplicationTests.RunnableTests; + +/// +/// Tests for RunnableSessionToken class. +/// +public class RunnableSessionTokenTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + [Fact] + public void RunnableSessionToken_Constructor_SetsRunnable () + { + // Arrange + Runnable runnable = new (); + + // Act + RunnableSessionToken token = new (runnable); + + // Assert + Assert.NotNull (token.Runnable); + Assert.Same (runnable, token.Runnable); + } + + [Fact] + public void RunnableSessionToken_Runnable_CanBeSetToNull () + { + // Arrange + Runnable runnable = new (); + RunnableSessionToken token = new (runnable); + + // Act + token.Runnable = null; + + // Assert + Assert.Null (token.Runnable); + } + + [Fact] + public void RunnableSessionToken_Dispose_ThrowsIfRunnableNotNull () + { + // Arrange + Runnable runnable = new (); + RunnableSessionToken token = new (runnable); + + // Act & Assert + Assert.Throws (() => token.Dispose ()); + } + + [Fact] + public void RunnableSessionToken_Dispose_SucceedsIfRunnableIsNull () + { + // Arrange + Runnable runnable = new (); + RunnableSessionToken token = new (runnable); + token.Runnable = null; + + // Act & Assert - should not throw + token.Dispose (); + } +} diff --git a/Tests/UnitTestsParallelizable/Application/Runnable/RunnableTests.cs b/Tests/UnitTestsParallelizable/Application/Runnable/RunnableTests.cs new file mode 100644 index 000000000..1d9b71ca8 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Application/Runnable/RunnableTests.cs @@ -0,0 +1,222 @@ +using Xunit.Abstractions; + +namespace UnitTests_Parallelizable.ApplicationTests.RunnableTests; + +/// +/// Tests for IRunnable interface and Runnable base class. +/// +public class RunnableTests (ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + [Fact] + public void Runnable_Implements_IRunnable () + { + // Arrange & Act + Runnable runnable = new (); + + // Assert + Assert.IsAssignableFrom (runnable); + Assert.IsAssignableFrom> (runnable); + } + + [Fact] + public void Runnable_Result_DefaultsToDefault () + { + // Arrange & Act + Runnable runnable = new (); + + // Assert + Assert.Equal (0, runnable.Result); + } + + [Fact] + public void Runnable_Result_CanBeSet () + { + // Arrange + Runnable runnable = new (); + + // Act + runnable.Result = 42; + + // Assert + Assert.Equal (42, runnable.Result); + } + + [Fact] + public void Runnable_Result_CanBeSetToNull () + { + // Arrange + Runnable runnable = new (); + + // Act + runnable.Result = null; + + // Assert + Assert.Null (runnable.Result); + } + + [Fact] + public void Runnable_IsRunning_ReturnsFalse_WhenNotRunning () + { + // Arrange + IApplication app = Application.Create (); + app.Init (); + Runnable runnable = new (); + + // Act & Assert + Assert.False (runnable.IsRunning); + + // Cleanup + app.Shutdown (); + } + + [Fact] + public void Runnable_IsModal_ReturnsFalse_WhenNotRunning () + { + // Arrange + Runnable runnable = new (); + + // Act & Assert + // IsModal should be false when the runnable has no app or is not TopRunnable + Assert.False (runnable.IsModal); + } + + [Fact] + public void RaiseIsRunningChanging_ClearsResult_WhenStarting () + { + // Arrange + Runnable runnable = new () { Result = 42 }; + + // Act + bool canceled = runnable.RaiseIsRunningChanging (false, true); + + // Assert + Assert.False (canceled); + Assert.Equal (0, runnable.Result); // Result should be cleared + } + + [Fact] + public void RaiseIsRunningChanging_CanBeCanceled_ByVirtualMethod () + { + // Arrange + CancelableRunnable runnable = new (); + + // Act + bool canceled = runnable.RaiseIsRunningChanging (false, true); + + // Assert + Assert.True (canceled); + } + + [Fact] + public void RaiseIsRunningChanging_CanBeCanceled_ByEvent () + { + // Arrange + Runnable runnable = new (); + var eventRaised = false; + + runnable.IsRunningChanging += (s, e) => + { + eventRaised = true; + e.Cancel = true; + }; + + // Act + bool canceled = runnable.RaiseIsRunningChanging (false, true); + + // Assert + Assert.True (eventRaised); + Assert.True (canceled); + } + + [Fact] + public void RaiseIsRunningChanged_RaisesEvent () + { + // Arrange + Runnable runnable = new (); + var eventRaised = false; + bool? receivedValue = null; + + runnable.IsRunningChanged += (s, e) => + { + eventRaised = true; + receivedValue = e.Value; + }; + + // Act + runnable.RaiseIsRunningChangedEvent (true); + + // Assert + Assert.True (eventRaised); + Assert.True (receivedValue); + } + + [Fact] + public void RaiseIsModalChanging_CanBeCanceled_ByVirtualMethod () + { + // Arrange + CancelableRunnable runnable = new () { CancelModalChange = true }; + + // Act + bool canceled = runnable.RaiseIsModalChanging (false, true); + + // Assert + Assert.True (canceled); + } + + [Fact] + public void RaiseIsModalChanging_CanBeCanceled_ByEvent () + { + // Arrange + Runnable runnable = new (); + var eventRaised = false; + + runnable.IsModalChanging += (s, e) => + { + eventRaised = true; + e.Cancel = true; + }; + + // Act + bool canceled = runnable.RaiseIsModalChanging (false, true); + + // Assert + Assert.True (eventRaised); + Assert.True (canceled); + } + + [Fact] + public void RaiseIsModalChanged_RaisesEvent () + { + // Arrange + Runnable runnable = new (); + var eventRaised = false; + bool? receivedValue = null; + + runnable.IsModalChanged += (s, e) => + { + eventRaised = true; + receivedValue = e.Value; + }; + + // Act + runnable.RaiseIsModalChangedEvent (true); + + // Assert + Assert.True (eventRaised); + Assert.True (receivedValue); + } + + /// + /// Test runnable that can cancel lifecycle changes. + /// + private class CancelableRunnable : Runnable + { + public bool CancelModalChange { get; set; } + + protected override bool OnIsRunningChanging (bool oldIsRunning, bool newIsRunning) => true; // Always cancel + + protected override bool OnIsModalChanging (bool oldIsModal, bool newIsModal) => CancelModalChange; + } +} diff --git a/docfx/docs/View.md b/docfx/docs/View.md index 33ac685fd..635ceb824 100644 --- a/docfx/docs/View.md +++ b/docfx/docs/View.md @@ -558,11 +558,134 @@ view.AddCommand(Command.ScrollDown, () => { view.ScrollVertical(1); return true; --- -## Modal Views +## Runnable Views (IRunnable) -Views can run modally (exclusively capturing all input until closed). See [Toplevel](~/api/Terminal.Gui.Views.Toplevel.yml) for details. +Views can implement [IRunnable](~/api/Terminal.Gui.App.IRunnable.yml) to run as independent, blocking sessions with typed results. This decouples runnability from inheritance, allowing any View to participate in session management. -### Running a View Modally +### IRunnable Architecture + +The **IRunnable** pattern provides: + +- **Interface-Based**: Implement `IRunnable` instead of inheriting from `Toplevel` +- **Type-Safe Results**: Generic `TResult` parameter for compile-time type safety +- **Fluent API**: Chain `Init()`, `Run()`, and `Shutdown()` for concise code +- **Automatic Disposal**: Framework manages lifecycle of created runnables +- **CWP Lifecycle Events**: `IsRunningChanging/Changed`, `IsModalChanging/Changed` + +### Creating a Runnable View + +Derive from [Runnable](~/api/Terminal.Gui.ViewBase.Runnable-1.yml) or implement [IRunnable](~/api/Terminal.Gui.App.IRunnable-1.yml): + +```csharp +public class ColorPickerDialog : Runnable +{ + private ColorPicker16 _colorPicker; + + public ColorPickerDialog() + { + Title = "Select a Color"; + + _colorPicker = new ColorPicker16 { X = Pos.Center(), Y = 2 }; + + var okButton = new Button { Text = "OK", IsDefault = true }; + okButton.Accepting += (s, e) => { + Result = _colorPicker.SelectedColor; + Application.RequestStop(); + }; + + Add(_colorPicker, okButton); + } +} +``` + +### Running with Fluent API + +The fluent API enables elegant, concise code with automatic disposal: + +```csharp +// Framework creates, runs, and disposes the runnable automatically +Color? result = Application.Create() + .Init() + .Run() + .Shutdown() as Color?; + +if (result is { }) +{ + Console.WriteLine($"Selected: {result}"); +} +``` + +### Running with Explicit Control + +For more control over the lifecycle: + +```csharp +var app = Application.Create(); +app.Init(); + +var dialog = new ColorPickerDialog(); +app.Run(dialog); + +// Extract result after Run returns +Color? result = dialog.Result; + +// Caller is responsible for disposal +dialog.Dispose(); + +app.Shutdown(); +``` + +### Disposal Semantics + +**"Whoever creates it, owns it":** + +- `Run()`: Framework creates → Framework disposes (in `Shutdown()`) +- `Run(IRunnable)`: Caller creates → Caller disposes + +### Result Extraction + +Extract the result in `OnIsRunningChanging` when stopping: + +```csharp +protected override bool OnIsRunningChanging(bool oldIsRunning, bool newIsRunning) +{ + if (!newIsRunning) // Stopping - extract result before disposal + { + Result = _colorPicker.SelectedColor; + + // Optionally cancel stop (e.g., prompt to save) + if (HasUnsavedChanges()) + { + return true; // Cancel stop + } + } + + return base.OnIsRunningChanging(oldIsRunning, newIsRunning); +} +``` + +### Lifecycle Properties + +- **`IsRunning`** - True when on the `RunnableSessionStack` +- **`IsModal`** - True when at the top of the stack (receiving all input) +- **`Result`** - The typed result value (set before stopping) + +### Lifecycle Events (CWP-Compliant) + +- **`IsRunningChanging`** - Cancellable event before added/removed from stack +- **`IsRunningChanged`** - Non-cancellable event after stack change +- **`IsModalChanging`** - Cancellable event before becoming/leaving top of stack +- **`IsModalChanged`** - Non-cancellable event after modal state change + +--- + +## Modal Views (Legacy) + +Views can run modally (exclusively capturing all input until closed). See [Toplevel](~/api/Terminal.Gui.Views.Toplevel.yml) for the legacy pattern. + +**Note:** New code should use `IRunnable` pattern (see above) for better type safety and lifecycle management. + +### Running a View Modally (Legacy) ```csharp var dialog = new Dialog @@ -580,16 +703,17 @@ dialog.Add(label); Application.Run(dialog); // Dialog has been closed +dialog.Dispose(); ``` -### Modal View Types +### Modal View Types (Legacy) - **[Toplevel](~/api/Terminal.Gui.Views.Toplevel.yml)** - Base class for modal views, can fill entire screen - **[Window](~/api/Terminal.Gui.Views.Window.yml)** - Overlapped container with border and title - **[Dialog](~/api/Terminal.Gui.Views.Dialog.yml)** - Modal Window, centered with button support - **[Wizard](~/api/Terminal.Gui.Views.Wizard.yml)** - Multi-step modal dialog -### Dialog Example +### Dialog Example (Legacy) [Dialogs](~/api/Terminal.Gui.Views.Dialog.yml) are Modal [Windows](~/api/Terminal.Gui.Views.Window.yml) centered on screen: diff --git a/docfx/docs/application.md b/docfx/docs/application.md index 530f53dc5..d624441b3 100644 --- a/docfx/docs/application.md +++ b/docfx/docs/application.md @@ -1,6 +1,15 @@ # Application Architecture -Terminal.Gui v2 uses an instance-based application architecture that decouples views from the global application state, improving testability and enabling multiple application contexts. +Terminal.Gui v2 uses an instance-based application architecture with the **IRunnable** interface pattern that decouples views from the global application state, improving testability, enabling multiple application contexts, and providing type-safe result handling. + +## Key Features + +- **Instance-Based**: Use `Application.Create()` to get an `IApplication` instance instead of static methods +- **IRunnable Interface**: Views implement `IRunnable` to participate in session management without inheriting from `Toplevel` +- **Fluent API**: Chain `Init()`, `Run()`, and `Shutdown()` for elegant, concise code +- **Automatic Disposal**: Framework-created runnables are automatically disposed +- **Type-Safe Results**: Generic `TResult` parameter provides compile-time type safety +- **CWP Compliance**: All lifecycle events follow the Cancellable Work Pattern ## View Hierarchy and Run Stack @@ -87,6 +96,12 @@ top.Add(myView); app.Run(top); top.Dispose(); app.Shutdown(); + +// NEWEST (v2 with IRunnable and Fluent API): +Color? result = Application.Create() + .Init() + .Run() + .Shutdown() as Color?; ``` **Note:** The static `Application` class delegates to `ApplicationImpl.Instance` (a singleton). `Application.Create()` creates a **new** `ApplicationImpl` instance, enabling multiple application contexts and better testability. @@ -158,32 +173,199 @@ public class MyView : View } ``` -## IApplication Interface +## IRunnable Architecture -The `IApplication` interface defines the application contract: +Terminal.Gui v2 introduces the **IRunnable** interface pattern that decouples runnable behavior from the `Toplevel` class hierarchy. Views can implement `IRunnable` to participate in session management without inheritance constraints. + +### Key Benefits + +- **Interface-Based**: No forced inheritance from `Toplevel` +- **Type-Safe Results**: Generic `TResult` parameter provides compile-time type safety +- **Fluent API**: Method chaining for elegant, concise code +- **Automatic Disposal**: Framework manages lifecycle of created runnables +- **CWP Compliance**: All lifecycle events follow the Cancellable Work Pattern + +### Fluent API Pattern + +The fluent API enables elegant method chaining with automatic resource management: + +```csharp +// All-in-one: Create, initialize, run, shutdown, and extract result +Color? result = Application.Create() + .Init() + .Run() + .Shutdown() as Color?; + +if (result is { }) +{ + ApplyColor(result); +} +``` + +**Key Methods:** + +- `Init()` - Returns `IApplication` for chaining +- `Run()` - Creates and runs runnable, returns `IApplication` +- `Shutdown()` - Disposes framework-owned runnables, returns `object?` result + +### Disposal Semantics + +**"Whoever creates it, owns it":** + +| Method | Creator | Owner | Disposal | +|--------|---------|-------|----------| +| `Run()` | Framework | Framework | Automatic in `Shutdown()` | +| `Run(IRunnable)` | Caller | Caller | Manual by caller | + +```csharp +// Framework ownership - automatic disposal +var result = app.Run().Shutdown(); + +// Caller ownership - manual disposal +var dialog = new MyDialog(); +app.Run(dialog); +var result = dialog.Result; +dialog.Dispose(); // Caller must dispose +``` + +### Creating Runnable Views + +Derive from `Runnable` or implement `IRunnable`: + +```csharp +public class FileDialog : Runnable +{ + private TextField _pathField; + + public FileDialog() + { + Title = "Select File"; + + _pathField = new TextField { X = 1, Y = 1, Width = Dim.Fill(1) }; + + var okButton = new Button { Text = "OK", IsDefault = true }; + okButton.Accepting += (s, e) => { + Result = _pathField.Text; + Application.RequestStop(); + }; + + Add(_pathField, okButton); + } + + protected override bool OnIsRunningChanging(bool oldValue, bool newValue) + { + if (!newValue) // Stopping - extract result before disposal + { + Result = _pathField?.Text; + } + return base.OnIsRunningChanging(oldValue, newValue); + } +} +``` + +### Lifecycle Properties + +- **`IsRunning`** - True when runnable is on `RunnableSessionStack` +- **`IsModal`** - True when runnable is at top of stack (capturing all input) +- **`Result`** - Typed result value set before stopping + +### Lifecycle Events (CWP-Compliant) + +All events follow Terminal.Gui's Cancellable Work Pattern: + +| Event | Cancellable | When | Use Case | +|-------|-------------|------|----------| +| `IsRunningChanging` | ✓ | Before add/remove from stack | Extract result, prevent close | +| `IsRunningChanged` | ✗ | After stack change | Post-start/stop cleanup | +| `IsModalChanging` | ✓ | Before becoming/leaving top | Prevent activation | +| `IsModalChanged` | ✗ | After modal state change | Update UI after focus change | + +**Example - Result Extraction:** + +```csharp +protected override bool OnIsRunningChanging(bool oldValue, bool newValue) +{ + if (!newValue) // Stopping + { + // Extract result before views are disposed + Result = _colorPicker.SelectedColor; + + // Optionally cancel stop (e.g., unsaved changes) + if (HasUnsavedChanges()) + { + int response = MessageBox.Query("Save?", "Save changes?", "Yes", "No", "Cancel"); + if (response == 2) return true; // Cancel stop + if (response == 0) Save(); + } + } + + return base.OnIsRunningChanging(oldValue, newValue); +} +``` + +### RunnableSessionStack + +The `RunnableSessionStack` manages all running `IRunnable` sessions: ```csharp public interface IApplication { /// - /// Gets the currently running Toplevel (the "current session"). - /// Renamed from "Top" for clarity. + /// Stack of running IRunnable sessions. + /// Each entry is a RunnableSessionToken wrapping an IRunnable. /// - Toplevel? Current { get; } + ConcurrentStack? RunnableSessionStack { get; } /// - /// Gets the stack of running sessions. - /// Renamed from "TopLevels" to align with SessionToken terminology. + /// The IRunnable at the top of RunnableSessionStack (currently modal). /// + IRunnable? TopRunnable { get; } +} +``` + +**Stack Behavior:** + +- Push: `Begin(IRunnable)` adds to top of stack +- Pop: `End(RunnableSessionToken)` removes from stack +- Peek: `TopRunnable` returns current modal runnable +- All: `RunnableSessionStack` enumerates all running sessions + +## IApplication Interface + +The `IApplication` interface defines the application contract with support for both legacy `Toplevel` and modern `IRunnable` patterns: + +```csharp +public interface IApplication +{ + // Legacy Toplevel support + Toplevel? Current { get; } ConcurrentStack SessionStack { get; } + // IRunnable support + IRunnable? TopRunnable { get; } + ConcurrentStack? RunnableSessionStack { get; } + IRunnable? FrameworkOwnedRunnable { get; set; } + + // Driver and lifecycle IDriver? Driver { get; } IMainLoopCoordinator? MainLoop { get; } - void Init(string? driverName = null); - void Shutdown(); + // Fluent API methods + IApplication Init(string? driverName = null); + object? Shutdown(); + + // Runnable methods + RunnableSessionToken Begin(IRunnable runnable); + void Run(IRunnable runnable, Func? errorHandler = null); + IApplication Run(Func? errorHandler = null) where TRunnable : IRunnable, new(); + void RequestStop(IRunnable? runnable); + void End(RunnableSessionToken sessionToken); + + // Legacy Toplevel methods SessionToken? Begin(Toplevel toplevel); + void Run(Toplevel view, Func? errorHandler = null); void End(SessionToken sessionToken); + // ... other members } ``` diff --git a/docfx/docs/runnable-architecture-proposal.md b/docfx/docs/runnable-architecture-proposal.md index 854095521..8903ede79 100644 --- a/docfx/docs/runnable-architecture-proposal.md +++ b/docfx/docs/runnable-architecture-proposal.md @@ -1,10 +1,12 @@ # IRunnable Architecture Proposal -**Status**: Proposal +**Status**: Phase 1 Complete ✅ - Phase 2 In Progress -**Version**: 1.7 - Approved - Implementing +**Version**: 1.8 - Phase 1 Implemented -**Date**: 2025-01-20 +**Date**: 2025-01-21 + +**Phase 1 Completion**: Issue #4400 closed with full implementation including fluent API and automatic disposal ## Summary @@ -1648,20 +1650,55 @@ fileDialog.Dispose (); - Rename `IApplication.Current` → `IApplication.TopRunnable` - Update `View.IsCurrentTop` → `View.IsTopRunnable` -### Phase 1: Add IRunnable Support +### Phase 1: Add IRunnable Support ✅ COMPLETE -- Issue #4400 +- Issue #4400 - **COMPLETED** -1. Add `IRunnable` (non-generic) interface alongside existing `Toplevel` -2. Add `IRunnable` (generic) interface -3. Add `Runnable` base class -4. Add `RunnableSessionToken` class -5. Update `IApplication.RunnableSessionStack` to hold `RunnableSessionToken` instead of `Toplevel` -6. Update `IApplication` to support both `Toplevel` and `IRunnable` -7. Implement CWP-based `IsRunningChanging`/`IsRunningChanged` events -8. Implement CWP-based `IsModalChanging`/`IsModalChanged` events -9. Update `Begin()`, `End()`, `RequestStop()` to raise these events -10. Add three `Run()` overloads: `Run(IRunnable)`, `Run()`, `Run()` +**Implemented:** + +1. ✅ Add `IRunnable` (non-generic) interface alongside existing `Toplevel` +2. ✅ Add `IRunnable` (generic) interface +3. ✅ Add `Runnable` base class +4. ✅ Add `RunnableSessionToken` class +5. ✅ Update `IApplication.RunnableSessionStack` to hold `RunnableSessionToken` +6. ✅ Update `IApplication` to support both `Toplevel` and `IRunnable` +7. ✅ Implement CWP-based `IsRunningChanging`/`IsRunningChanged` events +8. ✅ Implement CWP-based `IsModalChanging`/`IsModalChanged` events +9. ✅ Update `Begin()`, `End()`, `RequestStop()` to raise these events +10. ✅ Add `Run()` overloads: `Run(IRunnable)`, `Run()` + +**Bonus Features Added:** + +11. ✅ Fluent API - `Init()`, `Run()` return `IApplication` for method chaining +12. ✅ Automatic Disposal - `Shutdown()` returns result and disposes framework-owned runnables +13. ✅ Clear Ownership Semantics - "Whoever creates it, owns it" +14. ✅ 62 Parallelizable Unit Tests - Comprehensive test coverage +15. ✅ Example Application - `Examples/FluentExample` demonstrating the pattern +16. ✅ Complete API Documentation - XML docs for all new types + +**Key Design Decisions:** + +- Fluent API with `Init()` → `Run()` → `Shutdown()` chaining +- `Run()` returns `IApplication` (breaking change from returning `TRunnable`) +- `Shutdown()` returns `object?` (result from last run runnable) +- Framework automatically disposes runnables created by `Run()` +- Caller disposes runnables passed to `Run(IRunnable)` + +**Migration Example:** + +```csharp +// Before (manual disposal): +var dialog = new MyDialog(); +app.Run(dialog); +var result = dialog.Result; +dialog.Dispose(); + +// After (fluent with automatic disposal): +var result = Application.Create() + .Init() + .Run() + .Shutdown() as MyResultType; +``` ### Phase 2: Migrate Existing Views