diff --git a/Terminal.Gui/App/Application.Mouse.cs b/Terminal.Gui/App/Application.Mouse.cs index b92818d2d..6e06ab759 100644 --- a/Terminal.Gui/App/Application.Mouse.cs +++ b/Terminal.Gui/App/Application.Mouse.cs @@ -185,11 +185,7 @@ public static partial class Application // Mouse handling && Popover?.GetActivePopover () as View is { Visible: true } visiblePopover && View.IsInHierarchy (visiblePopover, deepestViewUnderMouse, includeAdornments: true) is false) { - // TODO: Build a use/test case for the popover not handling Quit - if (visiblePopover.InvokeCommand (Command.Quit) is true && visiblePopover.Visible) - { - visiblePopover.Visible = false; - } + ApplicationPopover.HideWithQuitCommand (visiblePopover); // Recurse once so the event can be handled below the popover RaiseMouseEvent (mouseEvent); @@ -205,10 +201,10 @@ public static partial class Application // Mouse handling if (Initialized) { WantContinuousButtonPressedView = deepestViewUnderMouse switch - { - { WantContinuousButtonPressed: true } => deepestViewUnderMouse, - _ => null - }; + { + { WantContinuousButtonPressed: true } => deepestViewUnderMouse, + _ => null + }; } // May be null before the prior condition or the condition may set it as null. diff --git a/Terminal.Gui/App/Application.Run.cs b/Terminal.Gui/App/Application.Run.cs index 860bed0fc..d20336e5f 100644 --- a/Terminal.Gui/App/Application.Run.cs +++ b/Terminal.Gui/App/Application.Run.cs @@ -577,11 +577,7 @@ public static partial class Application // Run (Begin, Run, End, Stop) if (Popover?.GetActivePopover () as View is { Visible: true } visiblePopover) { - // TODO: Build a use/test case for the popover not handling Quit - if (visiblePopover.InvokeCommand (Command.Quit) is true && visiblePopover.Visible) - { - visiblePopover.Visible = false; - } + ApplicationPopover.HideWithQuitCommand (visiblePopover); } runState.Toplevel.OnUnloaded (); diff --git a/Terminal.Gui/App/ApplicationPopover.cs b/Terminal.Gui/App/ApplicationPopover.cs index 061b10379..69430dbab 100644 --- a/Terminal.Gui/App/ApplicationPopover.cs +++ b/Terminal.Gui/App/ApplicationPopover.cs @@ -1,5 +1,6 @@ #nullable enable +using System.Diagnostics; namespace Terminal.Gui.App; @@ -36,12 +37,8 @@ public sealed class ApplicationPopover : IDisposable { if (popover is { } && !_popovers.Contains (popover)) { - // When created, set IPopover.Toplevel to the current Application.Top - if (popover.Toplevel is null) - { - popover.Toplevel = Application.Top; - } + popover.Toplevel ??= Application.Top; _popovers.Add (popover); } @@ -62,19 +59,20 @@ public sealed class ApplicationPopover : IDisposable /// public bool DeRegister (IPopover? popover) { - if (popover is { } && _popovers.Contains (popover)) + if (popover is null || !_popovers.Contains (popover)) { - if (GetActivePopover () == popover) - { - _activePopover = null; - } - - _popovers.Remove (popover); - - return true; + return false; } - return false; + if (GetActivePopover () == popover) + { + _activePopover = null; + } + + _popovers.Remove (popover); + + return true; + } private IPopover? _activePopover; @@ -111,6 +109,18 @@ public sealed class ApplicationPopover : IDisposable if (popover is View newPopover) { + if (!(newPopover.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent) && + newPopover.ViewportSettings.HasFlag (ViewportSettingsFlags.TransparentMouse))) + { + throw new InvalidOperationException ("Popovers must have ViewportSettings.Transparent and ViewportSettings.TransparentMouse set."); + } + + if (newPopover.KeyBindings.GetFirstFromCommands (Command.Quit) is null) + { + throw new InvalidOperationException ("Popovers must have a key binding for Command.Quit."); + } + + Register (popover); if (!newPopover.IsInitialized) @@ -127,7 +137,7 @@ public sealed class ApplicationPopover : IDisposable /// /// Causes the specified popover to be hidden. - /// If the popover is dervied from , this is the same as setting + /// If the popover is derived from , this is the same as setting /// to . /// /// @@ -142,6 +152,22 @@ public sealed class ApplicationPopover : IDisposable } } + /// + /// Hides a popover view if it supports the quit command and is currently visible. It checks for the command's + /// support before hiding. + /// + /// The view that is being checked and potentially hidden based on its visibility and command support. + internal static void HideWithQuitCommand (View visiblePopover) + { + if (visiblePopover.Visible + && (!visiblePopover.GetSupportedCommands ().Contains (Command.Quit) + || (visiblePopover.InvokeCommand (Command.Quit) is true && visiblePopover.Visible))) + { + visiblePopover.Visible = false; + } + } + + /// /// Called when the user presses a key. Dispatches the key to the active popover, if any, /// otherwise to the popovers in the order they were registered. Inactive popovers only get hotkeys. @@ -155,7 +181,7 @@ public sealed class ApplicationPopover : IDisposable if (activePopover is { Visible: true }) { - Logging.Debug ($"Active - Calling NewKeyDownEvent ({key}) on {activePopover.Title}"); + //Logging.Debug ($"Active - Calling NewKeyDownEvent ({key}) on {activePopover.Title}"); if (activePopover.NewKeyDownEvent (key)) { @@ -177,7 +203,7 @@ public sealed class ApplicationPopover : IDisposable } // hotKeyHandled = popoverView.InvokeCommandsBoundToHotKey (key); - Logging.Debug ($"Inactive - Calling NewKeyDownEvent ({key}) on {popoverView.Title}"); + //Logging.Debug ($"Inactive - Calling NewKeyDownEvent ({key}) on {popoverView.Title}"); hotKeyHandled = popoverView.NewKeyDownEvent (key); if (hotKeyHandled is true) diff --git a/Terminal.Gui/App/IPopover.cs b/Terminal.Gui/App/IPopover.cs index cbff58597..652ca4614 100644 --- a/Terminal.Gui/App/IPopover.cs +++ b/Terminal.Gui/App/IPopover.cs @@ -3,8 +3,32 @@ namespace Terminal.Gui.App; /// -/// Interface identifying a View as being capable of being a Popover. +/// Defines the contract for a popover view in Terminal.Gui. /// +/// +/// +/// A popover is a transient UI element that appears above other content to display contextual information or UI, +/// such as menus, tooltips, or dialogs. +/// Popovers are managed by and are typically shown using +/// . +/// +/// +/// Popovers are not modal; they do not block input to the rest of the application, but they do receive focus and +/// input events while visible. +/// When a popover is shown, it is responsible for handling its own layout and content. +/// +/// +/// Popovers are automatically hidden when: +/// +/// The user clicks outside the popover (unless occluded by a subview of the popover). +/// The user presses (typically Esc). +/// Another popover is shown. +/// +/// +/// +/// To implement a custom popover, inherit from or implement this interface directly. +/// +/// public interface IPopover { /// @@ -15,5 +39,5 @@ public interface IPopover /// When is called, the is set to the current /// if not already set. /// - public Toplevel? Toplevel { get; set; } + Toplevel? Toplevel { get; set; } } diff --git a/Terminal.Gui/App/PopoverBaseImpl.cs b/Terminal.Gui/App/PopoverBaseImpl.cs index 3800d886c..8c426a2db 100644 --- a/Terminal.Gui/App/PopoverBaseImpl.cs +++ b/Terminal.Gui/App/PopoverBaseImpl.cs @@ -3,24 +3,41 @@ namespace Terminal.Gui.App; /// -/// Abstract base class for Popover Views. +/// Abstract base class for popover views in Terminal.Gui. /// /// /// -/// To show a Popover, use . To hide a popover, -/// call with set to . +/// Popover Lifecycle:
+/// To display a popover, use . To hide a popover, either call +/// , +/// set to , or show another popover. ///
/// -/// If the user clicks anywhere not occluded by a SubView of the Popover, presses , -/// or causes another popover to show, the Popover will be hidden. +/// Focus and Input:
+/// When visible, a popover receives focus and input events. If the user clicks outside the popover (and not on a +/// subview), +/// presses , or another popover is shown, the popover will be hidden +/// automatically. +///
+/// +/// Layout:
+/// When the popover becomes visible, it is automatically laid out to fill the screen by default. You can override +/// this behavior +/// by setting and in your derived class. +///
+/// +/// Custom Popovers:
+/// To create a custom popover, inherit from and add your own content and logic. ///
///
public abstract class PopoverBaseImpl : View, IPopover { - /// - /// Creates a new PopoverBaseImpl. + /// Initializes a new instance of the class. /// + /// + /// By default, the popover fills the available screen area and is focusable. + /// protected PopoverBaseImpl () { Id = "popoverBaseImpl"; @@ -52,32 +69,43 @@ public abstract class PopoverBaseImpl : View, IPopover } } - /// + /// + public Toplevel? Toplevel { get; set; } + + /// + /// Called when the property is changing. + /// + /// + /// When becoming visible, the popover is laid out to fit the screen. + /// When becoming hidden, focus is restored to the previous view. + /// + /// + /// to cancel the visibility change; otherwise, . + /// protected override bool OnVisibleChanging () { bool ret = base.OnVisibleChanging (); - if (ret is not true) + + if (ret) { - if (!Visible) + return ret; + } + + if (!Visible) + { + // Whenever visible is changing to true, we need to resize; + // it's our only chance because we don't get laid out until we're visible + Layout (Application.Screen.Size); + } + else + { + // Whenever visible is changing to false, we need to reset the focus + if (ApplicationNavigation.IsInHierarchy (this, Application.Navigation?.GetFocused ())) { - // Whenever visible is changing to true, we need to resize; - // it's our only chance because we don't get laid out until we're visible - Layout (Application.Screen.Size); - } - else - { - // Whenever visible is changing to false, we need to reset the focus - if (ApplicationNavigation.IsInHierarchy(this, Application.Navigation?.GetFocused ())) - { - Application.Navigation?.SetFocused (Application.Top?.MostFocused); - } + Application.Navigation?.SetFocused (Application.Top?.MostFocused); } } return ret; } - - /// - public Toplevel? Toplevel { get; set; } - } diff --git a/Terminal.Gui/Input/InputBindings.cs b/Terminal.Gui/Input/InputBindings.cs index 9c2b446e1..75a4711e5 100644 --- a/Terminal.Gui/Input/InputBindings.cs +++ b/Terminal.Gui/Input/InputBindings.cs @@ -160,7 +160,7 @@ public abstract class InputBindings where TBinding : IInputBin /// The set of commands to search. /// /// The first matching bound to the set of commands specified by - /// . if the set of caommands was not found. + /// . if the set of commands was not found. /// public TEvent? GetFirstFromCommands (params Command [] commands) { return _bindings.FirstOrDefault (a => a.Value.Commands.SequenceEqual (commands)).Key; } @@ -169,7 +169,7 @@ public abstract class InputBindings where TBinding : IInputBin /// /// The s bound to the set of commands specified by . An empty list if /// the - /// set of caommands was not found. + /// set of commands was not found. /// public IEnumerable GetAllFromCommands (params Command [] commands) { diff --git a/Terminal.Gui/Views/Menu/PopoverMenu.cs b/Terminal.Gui/Views/Menu/PopoverMenu.cs index c2bd0d5ed..dcaef66ea 100644 --- a/Terminal.Gui/Views/Menu/PopoverMenu.cs +++ b/Terminal.Gui/Views/Menu/PopoverMenu.cs @@ -65,6 +65,8 @@ public class PopoverMenu : PopoverBaseImpl, IDesignable AddCommand (Command.Left, MoveLeft); KeyBindings.Add (Key.CursorLeft, Command.Left); + // PopoverBaseImpl sets a key binding for Quit, so we + // don't need to do it here. AddCommand (Command.Quit, Quit); return; diff --git a/Tests/UnitTests/Application/ApplicationPopoverTests.cs b/Tests/UnitTests/Application/ApplicationPopoverTests.cs index 422b670e8..66395840a 100644 --- a/Tests/UnitTests/Application/ApplicationPopoverTests.cs +++ b/Tests/UnitTests/Application/ApplicationPopoverTests.cs @@ -21,6 +21,7 @@ public class ApplicationPopoverTests [Fact] public void Application_Shutdown_Resets_PopoverManager () { + Application.ResetState (true); // Arrange Assert.Null (Application.Popover); Application.Init (new FakeDriver ()); @@ -37,6 +38,7 @@ public class ApplicationPopoverTests [Fact] public void Application_End_Does_Not_Reset_PopoverManager () { + Application.ResetState (true); // Arrange Assert.Null (Application.Popover); Application.Init (new FakeDriver ()); @@ -59,6 +61,7 @@ public class ApplicationPopoverTests [Fact] public void Application_End_Hides_Active () { + Application.ResetState (true); // Arrange Assert.Null (Application.Popover); Application.Init (new FakeDriver ()); @@ -87,6 +90,7 @@ public class ApplicationPopoverTests [Fact] public void Application_Shutdown_Disposes_Registered_Popovers () { + Application.ResetState (true); // Arrange Assert.Null (Application.Popover); Application.Init (new FakeDriver ()); @@ -104,6 +108,7 @@ public class ApplicationPopoverTests [Fact] public void Application_Shutdown_Does_Not_Dispose_DeRegistered_Popovers () { + Application.ResetState (true); // Arrange Assert.Null (Application.Popover); Application.Init (new FakeDriver ()); @@ -126,6 +131,7 @@ public class ApplicationPopoverTests [Fact] public void Application_Shutdown_Does_Not_Dispose_ActiveNotRegistered_Popover () { + Application.ResetState (true); // Arrange Assert.Null (Application.Popover); Application.Init (new FakeDriver ()); @@ -148,6 +154,7 @@ public class ApplicationPopoverTests [Fact] public void Register_SetsTopLevel () { + Application.ResetState (true); // Arrange Assert.Null (Application.Popover); Application.Init (new FakeDriver ()); @@ -166,6 +173,7 @@ public class ApplicationPopoverTests [Fact] public void Keyboard_Events_Go_Only_To_Popover_Associated_With_Toplevel () { + Application.ResetState (true); // Arrange Assert.Null (Application.Popover); Application.Init (new FakeDriver ()); diff --git a/Tests/UnitTestsParallelizable/Application/ApplicationPopoverTests.cs b/Tests/UnitTestsParallelizable/Application/ApplicationPopoverTests.cs index ab9746ca4..c34ddd7a5 100644 --- a/Tests/UnitTestsParallelizable/Application/ApplicationPopoverTests.cs +++ b/Tests/UnitTestsParallelizable/Application/ApplicationPopoverTests.cs @@ -144,10 +144,28 @@ public class ApplicationPopoverTests public PopoverTestClass () { + ViewportSettings = ViewportSettingsFlags.Transparent | ViewportSettingsFlags.TransparentMouse; CanFocus = true; AddCommand (Command.New, NewCommandHandler!); HotKeyBindings.Add (Key.N.WithCtrl, Command.New); + AddCommand (Command.Quit, Quit); + KeyBindings.Add (Application.QuitKey, Command.Quit); + + return; + + bool? Quit (ICommandContext? ctx) + { + if (!Visible) + { + return false; + } + + Visible = false; + + return true; + } + bool? NewCommandHandler (ICommandContext ctx) { NewCommandInvokeCount++; diff --git a/Tests/UnitTestsParallelizable/Application/PopoverBaseImplTests.cs b/Tests/UnitTestsParallelizable/Application/PopoverBaseImplTests.cs new file mode 100644 index 000000000..7d8df46a8 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Application/PopoverBaseImplTests.cs @@ -0,0 +1,68 @@ +using System; +using Terminal.Gui; +using Terminal.Gui.App; +using Xunit; + +public class PopoverBaseImplTests +{ + // Minimal concrete implementation for testing + private class TestPopover : PopoverBaseImpl { } + + [Fact] + public void Constructor_SetsDefaults () + { + var popover = new TestPopover (); + + Assert.Equal ("popoverBaseImpl", popover.Id); + Assert.True (popover.CanFocus); + Assert.Equal (Dim.Fill (), popover.Width); + Assert.Equal (Dim.Fill (), popover.Height); + Assert.True (popover.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent)); + Assert.True (popover.ViewportSettings.HasFlag (ViewportSettingsFlags.TransparentMouse)); + } + + [Fact] + public void Toplevel_Property_CanBeSetAndGet () + { + var popover = new TestPopover (); + var top = new Toplevel (); + popover.Toplevel = top; + Assert.Same (top, popover.Toplevel); + } + + [Fact] + public void Show_ThrowsIfPopoverMissingRequiredFlags () + { + var popover = new TestPopover (); + + // Popover missing Transparent flags + popover.ViewportSettings = ViewportSettingsFlags.None; // Remove required flags + + var popoverManager = new ApplicationPopover (); + // Test missing Transparent flags + Assert.ThrowsAny (() => popoverManager.Show (popover)); + + } + + + [Fact] + public void Show_ThrowsIfPopoverMissingQuitCommand () + { + var popover = new TestPopover (); + + // Popover missing Command.Quit binding + popover.KeyBindings.Clear (); // Remove all key bindings + + var popoverManager = new ApplicationPopover (); + Assert.ThrowsAny (() => popoverManager.Show (popover)); + } + + [Fact] + public void Show_DoesNotThrow_BasePopoverImpl () + { + var popover = new TestPopover (); + + var popoverManager = new ApplicationPopover (); + popoverManager.Show (popover); + } +}