Bladeren bron

Add PopoverWrapper and enhance DropDownListExample

Refactored `DropDownListExample` for modularity and lifecycle management. Introduced `PopoverWrapper<TView>` to enable any `View` to function as a popover, along with `ViewPopoverExtensions` for a fluent API. Added `PopoverWrapperExample` project to demonstrate usage with examples like `ListView`, forms, and `ColorPicker`.

Enhanced `Shortcut` class with a configurable `MarginThickness` property. Updated `PopoverBaseImpl` to redraw UI on visibility changes. Added comprehensive unit tests for `PopoverWrapper` and extensions. Updated `Terminal.sln` to include the new project. Added detailed documentation in `README.md`.

Improved code maintainability, modularity, and user experience.
Tig 1 week geleden
bovenliggende
commit
01c94ce5b2

+ 15 - 0
Examples/PopoverWrapperExample/PopoverWrapperExample.csproj

@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net8.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <LangVersion>latest</LangVersion>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\..\Terminal.Gui\Terminal.Gui.csproj" />
+  </ItemGroup>
+
+</Project>

+ 417 - 0
Examples/PopoverWrapperExample/Program.cs

@@ -0,0 +1,417 @@
+// Example demonstrating how to make ANY View into a popover without implementing IPopover
+
+using Terminal.Gui;
+using Terminal.Gui.App;
+using Terminal.Gui.Configuration;
+using Terminal.Gui.Drawing;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+IApplication app = Application.Create ();
+app.Init ();
+
+// Create a main window with some buttons to trigger popovers
+Window mainWindow = new ()
+{
+    Title = "PopoverWrapper Example - Press buttons to show popovers",
+    X = 0,
+    Y = 0,
+    Width = Dim.Fill (),
+    Height = Dim.Fill ()
+};
+
+Label label = new ()
+{
+    Text = "Click buttons below or press their hotkeys to show different popovers.\nPress Esc to close a popover.",
+    X = Pos.Center (),
+    Y = 1,
+    Width = Dim.Fill (),
+    Height = 2,
+    TextAlignment = Alignment.Center
+};
+
+mainWindow.Add (label);
+
+// Example 1: Simple view as popover
+Button button1 = new ()
+{
+    Title = "_1: Simple View Popover",
+    X = Pos.Center (),
+    Y = Pos.Top (label) + 3
+};
+
+button1.Accepting += (s, e) =>
+{
+    IApplication? application = (s as View)?.App;
+
+    if (application is null)
+    {
+        return;
+    }
+
+    View simpleView = new ()
+    {
+        Title = "Simple Popover",
+        Width = Dim.Auto (),
+        Height = Dim.Auto (),
+        SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Menu)
+    };
+
+    simpleView.Add (
+                    new Label
+                    {
+                        Text = "This is a simple View wrapped as a popover!\n\nPress Esc or click outside to dismiss.",
+                        X = Pos.Center (),
+                        Y = Pos.Center (),
+                        TextAlignment = Alignment.Center
+                    });
+
+    PopoverWrapper<View> popover = simpleView.AsPopover ();
+    popover.X = Pos.Center ();
+    popover.Y = Pos.Center ();
+    application.Popover?.Register (popover);
+    application.Popover?.Show (popover);
+
+    e.Handled = true;
+};
+
+mainWindow.Add (button1);
+
+// Example 2: ListView as popover
+Button button2 = new ()
+{
+    Title = "_2: ListView Popover",
+    X = Pos.Center (),
+    Y = Pos.Bottom (button1) + 1
+};
+
+ListView listView = new ()
+{
+    Title = "Select an Item",
+    X = Pos.Center (),
+    Y = Pos.Center (),
+    Width = Dim.Percent (30),
+    Height = Dim.Percent (40),
+    BorderStyle = LineStyle.Single,
+    Source = new ListWrapper<string> (["Apple", "Banana", "Cherry", "Date", "Elderberry", "Fig", "Grape"]),
+    SelectedItem = 0
+};
+
+PopoverWrapper<ListView> listViewPopover = listView.AsPopover ();
+
+listView.SelectedItemChanged += (sender, args) =>
+{
+    listViewPopover.Visible = false;
+
+    if (listView.SelectedItem is { } selectedItem && selectedItem >= 0)
+    {
+        MessageBox.Query (app, "Selected", $"You selected: {listView.Source.ToList () [selectedItem]}", "OK");
+    }
+};
+
+button2.Accepting += (s, e) =>
+{
+    IApplication? application = (s as View)?.App;
+
+    if (application is null)
+    {
+        return;
+    }
+
+    listViewPopover.X = Pos.Center ();
+    listViewPopover.Y = Pos.Center ();
+    application.Popover?.Register (listViewPopover);
+    application.Popover?.Show (listViewPopover);
+
+    e.Handled = true;
+};
+
+mainWindow.Add (button2);
+
+// Example 3: Form as popover
+Button button3 = new ()
+{
+    Title = "_3: Form Popover",
+    X = Pos.Center (),
+    Y = Pos.Bottom (button2) + 1
+};
+
+button3.Accepting += (s, e) =>
+{
+    IApplication? application = (s as View)?.App;
+
+    if (application is null)
+    {
+        return;
+    }
+
+    View formView = CreateFormView (application);
+    PopoverWrapper<View> popover = formView.AsPopover ();
+    popover.X = Pos.Center ();
+    popover.Y = Pos.Center ();
+    application.Popover?.Register (popover);
+    application.Popover?.Show (popover);
+    e.Handled = true;
+};
+
+mainWindow.Add (button3);
+
+// Example 4: ColorPicker as popover
+Button button4 = new ()
+{
+    Title = "_4: ColorPicker Popover",
+    X = Pos.Center (),
+    Y = Pos.Bottom (button3) + 1
+};
+
+button4.Accepting += (s, e) =>
+{
+    IApplication? application = (s as View)?.App;
+
+    if (application is null)
+    {
+        return;
+    }
+
+    ColorPicker colorPicker = new ()
+    {
+        Title = "Pick a Border Color",
+        BorderStyle = LineStyle.Single
+    };
+
+    colorPicker.Selecting += (sender, args) =>
+    {
+        ColorPicker? picker = sender as ColorPicker;
+
+        if (picker is { })
+        {
+            Scheme old = application.TopRunnableView.Border.GetScheme ();
+            application.TopRunnableView.Border.SetScheme (old with { Normal = new Attribute (picker.SelectedColor, Color.Black) });
+        }
+        args.Handled = true;
+    };
+
+    PopoverWrapper<ColorPicker> popover = colorPicker.AsPopover ();
+    popover.X = Pos.Center ();
+    popover.Y = Pos.Center ();
+    application.Popover?.Register (popover);
+    application.Popover?.Show (popover);
+
+    e.Handled = true;
+};
+
+mainWindow.Add (button4);
+
+// Example 5: Custom position and size
+Button button5 = new ()
+{
+    Title = "_5: Positioned Popover",
+    X = Pos.Center (),
+    Y = Pos.Bottom (button4) + 1
+};
+
+button5.Accepting += (s, e) =>
+{
+    IApplication? application = (s as View)?.App;
+
+    if (application is null)
+    {
+        return;
+    }
+
+    View customView = new ()
+    {
+        Title = "Custom Position",
+        X = Pos.Percent (10),
+        Y = Pos.Percent (10),
+        Width = Dim.Percent (50),
+        Height = Dim.Percent (60),
+        BorderStyle = LineStyle.Double
+    };
+
+    customView.Add (
+                    new Label
+                    {
+                        Text = "This popover has a custom position and size.\n\nYou can set X, Y, Width, and Height\nusing Pos and Dim to position it anywhere.",
+                        X = 2,
+                        Y = 2
+                    });
+
+    Button closeButton = new ()
+    {
+        Title = "Close",
+        X = Pos.Center (),
+        Y = Pos.AnchorEnd (1)
+    };
+
+    closeButton.Accepting += (sender, args) =>
+    {
+        if (customView.SuperView is PopoverWrapper<View> wrapper)
+        {
+            wrapper.Visible = false;
+        }
+
+        args.Handled = true;
+    };
+
+    customView.Add (closeButton);
+
+    PopoverWrapper<View> popover = customView.AsPopover ();
+    application.Popover?.Register (popover);
+    popover.X = Pos.Center ();
+    popover.Y = Pos.Center ();
+    application.Popover?.Show (popover);
+
+    e.Handled = true;
+};
+
+mainWindow.Add (button5);
+
+// Quit button
+Button quitButton = new ()
+{
+    Title = "_Quit",
+    X = Pos.Center (),
+    Y = Pos.AnchorEnd (1)
+};
+
+quitButton.Accepting += (s, e) =>
+{
+    app.RequestStop ();
+    e.Handled = true;
+};
+
+mainWindow.Add (quitButton);
+
+app.Run (mainWindow);
+mainWindow.Dispose ();
+app.Dispose ();
+
+// Helper method to create a form view
+View CreateFormView (IApplication application)
+{
+    View form = new ()
+    {
+        Title = "User Registration Form",
+        X = Pos.Center (),
+        Y = Pos.Center (),
+        Width = Dim.Percent (60),
+        Height = Dim.Percent (50),
+        BorderStyle = LineStyle.Single
+    };
+
+    Label nameLabel = new ()
+    {
+        Text = "Name:",
+        X = 2,
+        Y = 1
+    };
+
+    TextField nameField = new ()
+    {
+        X = Pos.Right (nameLabel) + 2,
+        Y = Pos.Top (nameLabel),
+        Width = Dim.Fill (2)
+    };
+
+    Label emailLabel = new ()
+    {
+        Text = "Email:",
+        X = Pos.Left (nameLabel),
+        Y = Pos.Bottom (nameLabel) + 1
+    };
+
+    TextField emailField = new ()
+    {
+        X = Pos.Right (emailLabel) + 2,
+        Y = Pos.Top (emailLabel),
+        Width = Dim.Fill (2)
+    };
+
+    Label ageLabel = new ()
+    {
+        Text = "Age:",
+        X = Pos.Left (nameLabel),
+        Y = Pos.Bottom (emailLabel) + 1
+    };
+
+    TextField ageField = new ()
+    {
+        X = Pos.Right (ageLabel) + 2,
+        Y = Pos.Top (ageLabel),
+        Width = Dim.Percent (20)
+    };
+
+    CheckBox agreeCheckbox = new ()
+    {
+        Title = "I agree to the terms and conditions",
+        X = Pos.Left (nameLabel),
+        Y = Pos.Bottom (ageLabel) + 1
+    };
+
+    Button submitButton = new ()
+    {
+        Title = "Submit",
+        X = Pos.Center () - 8,
+        Y = Pos.AnchorEnd (2),
+        IsDefault = true
+    };
+
+    submitButton.Accepting += (s, e) =>
+    {
+        if (string.IsNullOrWhiteSpace (nameField.Text))
+        {
+            MessageBox.ErrorQuery (application, "Error", "Name is required!", "OK");
+            e.Handled = true;
+
+            return;
+        }
+
+        if (agreeCheckbox.CheckedState != CheckState.Checked)
+        {
+            MessageBox.ErrorQuery (application, "Error", "You must agree to the terms!", "OK");
+            e.Handled = true;
+
+            return;
+        }
+
+        MessageBox.Query (
+                          application,
+                          "Success",
+                          $"Registration submitted!\n\nName: {nameField.Text}\nEmail: {emailField.Text}\nAge: {ageField.Text}",
+                          "OK");
+
+        if (form.SuperView is PopoverWrapper<View> wrapper)
+        {
+            wrapper.Visible = false;
+        }
+
+        e.Handled = true;
+    };
+
+    Button cancelButton = new ()
+    {
+        Title = "Cancel",
+        X = Pos.Center () + 4,
+        Y = Pos.Top (submitButton)
+    };
+
+    cancelButton.Accepting += (s, e) =>
+    {
+        if (form.SuperView is PopoverWrapper<View> wrapper)
+        {
+            wrapper.Visible = false;
+        }
+
+        e.Handled = true;
+    };
+
+    form.Add (nameLabel, nameField);
+    form.Add (emailLabel, emailField);
+    form.Add (ageLabel, ageField);
+    form.Add (agreeCheckbox);
+    form.Add (submitButton, cancelButton);
+
+    return form;
+}

+ 108 - 0
Examples/PopoverWrapperExample/README.md

@@ -0,0 +1,108 @@
+# PopoverWrapper Example
+
+This example demonstrates how to use `PopoverWrapper<TView>` to make any View into a popover without implementing the `IPopover` interface.
+
+## Overview
+
+`PopoverWrapper<TView>` is similar to `RunnableWrapper<TView, TResult>` but for popovers instead of runnables. It wraps any View and automatically handles:
+
+- Setting proper viewport settings (transparent, transparent mouse)
+- Configuring focus behavior
+- Handling the quit command to hide the popover
+- Sizing to fill the screen by default
+
+## Key Features
+
+- **Fluent API**: Use `.AsPopover()` extension method for a clean, fluent syntax
+- **Any View**: Wrap any existing View - Button, ListView, custom Views, forms, etc.
+- **Automatic Management**: The wrapper handles all the popover boilerplate
+- **Type-Safe**: Generic type parameter ensures type safety when accessing the wrapped view
+
+## Usage
+
+### Basic Usage
+
+```csharp
+// Create any view
+var myView = new View
+{
+    X = Pos.Center (),
+    Y = Pos.Center (),
+    Width = 40,
+    Height = 10,
+    BorderStyle = LineStyle.Single
+};
+
+// Wrap it as a popover
+PopoverWrapper<View> popover = myView.AsPopover ();
+
+// Register and show
+app.Popover.Register (popover);
+app.Popover.Show (popover);
+```
+
+### With ListView
+
+```csharp
+var listView = new ListView
+{
+    Title = "Select an Item",
+    X = Pos.Center (),
+    Y = Pos.Center (),
+    Width = 30,
+    Height = 10,
+    Source = new ListWrapper<string> (["Apple", "Banana", "Cherry"])
+};
+
+PopoverWrapper<ListView> popover = listView.AsPopover ();
+app.Popover.Register (popover);
+app.Popover.Show (popover);
+```
+
+### With Custom Forms
+
+```csharp
+View CreateFormView ()
+{
+    var form = new View
+    {
+        Title = "User Form",
+        X = Pos.Center (),
+        Y = Pos.Center (),
+        Width = 60,
+        Height = 16
+    };
+    
+    // Add form fields...
+    
+    return form;
+}
+
+View formView = CreateFormView ();
+PopoverWrapper<View> popover = formView.AsPopover ();
+app.Popover.Register (popover);
+app.Popover.Show (popover);
+```
+
+## Comparison with RunnableWrapper
+
+| Feature | RunnableWrapper | PopoverWrapper |
+|---------|----------------|----------------|
+| Purpose | Make any View runnable as a modal session | Make any View into a popover |
+| Blocking | Yes, blocks until stopped | No, non-blocking overlay |
+| Result Extraction | Yes, via typed Result property | N/A (access WrappedView directly) |
+| Dismissal | Via RequestStop() or Quit command | Via Quit command or clicking outside |
+| Focus | Takes exclusive focus | Shares focus with underlying content |
+
+## Running the Example
+
+```bash
+dotnet run --project Examples/PopoverWrapperExample
+```
+
+## See Also
+
+- [Popovers Deep Dive](../../docfx/docs/Popovers.md)
+- [RunnableWrapper Example](../RunnableWrapperExample/)
+- `Terminal.Gui.App.PopoverBaseImpl`
+- `Terminal.Gui.App.IPopover`

+ 134 - 21
Examples/UICatalog/Scenarios/DropDownListExample.cs

@@ -18,40 +18,153 @@ public sealed class DropDownListExample : Scenario
             BorderStyle = LineStyle.None
         };
 
-        Label l = new Label () { Title = "_DropDown:" };
+        Label label = new Label () { Title = "_DropDown TextField Using Menu:" };
+        View view = CreateDropDownTextFieldUsingMenu ();
+        view.X = Pos.Right (label) + 1;
 
-        TextField tf = new () { X = Pos.Right(l), Width = 10 };
+        appWindow.Add (label, view);
+
+        // Run - Start the application.
+        Application.Run (appWindow);
+        appWindow.Dispose ();
+
+        // Shutdown - Calling Application.Shutdown is required.
+        Application.Shutdown ();
+    }
+
+    private View CreateDropDownTextFieldUsingMenu ()
+    {
+
+        TextField tf = new ()
+        {
+            Text = "item 1",
+            Width = 10,
+            Height = 1
+        };
+
+        MenuBarItem? menuBarItem = new ($"{Glyphs.DownArrow}", Enumerable.Range (1, 5)
+                                                                          .Select (i =>
+                                                                          {
+                                                                              MenuItem item = new MenuItem ($"item {i}", null, null, null);
+                                                                              item.Accepting += (s, e) =>
+                                                                              {
+                                                                                  tf.Text = item.Title;
+                                                                                  //e.Handled = true;
+                                                                              };
+
+                                                                              return item;
+                                                                          })
+                                                                          .ToArray ())
+        {
+            MarginThickness = Thickness.Empty
+        };
+
+        menuBarItem.PopoverMenuOpenChanged += (s, e) =>
+        {
+            if (e.Value && s is MenuBarItem sender)
+            {
+                sender.PopoverMenu!.Root.X = tf.FrameToScreen ().X;
+                sender.PopoverMenu.Root.Width = tf.Width + sender.Width;
+                // Find the subview of Root whos Text matches tf.Text and setfocus to it
+                var menuItemToSelect = sender.PopoverMenu.Root.SubViews.OfType<MenuItem> ().FirstOrDefault (mi => mi.Title == tf.Text.ToString ());
+                menuItemToSelect?.SetFocus ();
+            }
+        };
 
-        MenuBarItem? menuBarItem  = new ($"{Glyphs.DownArrow}",
-                                Enumerable.Range (1, 5)
-                                          .Select (selector: i => new MenuItem($"item {i}", null, null, null) )
-                                          .ToArray ());
 
         var mb = new MenuBar ([menuBarItem])
         {
             CanFocus = true,
-            Width = 1,
+            Width = Dim.Auto (),
             Y = Pos.Top (tf),
             X = Pos.Right (tf)
         };
 
         // HACKS required to make this work:
-        mb.Accepted += (s, e) => {
-                        // BUG: This does not select menu item 0
-                        // Instead what happens is the first keystroke the user presses
-                        // gets swallowed and focus is moved to 0.  Result is that you have
-                        // to press down arrow twice to select first menu item and/or have to
-                        // press Tab twice to move focus back to TextField
-                        mb.OpenMenu ();
-                    };
+        mb.Accepted += (s, e) =>
+        {
+            // BUG: This does not select menu item 0
+            // Instead what happens is the first keystroke the user presses
+            // gets swallowed and focus is moved to 0.  Result is that you have
+            // to press down arrow twice to select first menu item and/or have to
+            // press Tab twice to move focus back to TextField
+            mb.OpenMenu ();
+        };
 
-        appWindow.Add (l, tf, mb);
+        View superView = new ()
+        {
+            CanFocus = true,
+            Height = Dim.Auto (),
+            Width = Dim.Auto()
+        };
+        superView.Add (tf, mb);
 
-        // Run - Start the application.
-        Application.Run (appWindow);
-        appWindow.Dispose ();
+        return superView;
 
-        // Shutdown - Calling Application.Shutdown is required.
-        Application.Shutdown ();
     }
+
+    //private View  CreateDropDownTextFieldUsingListView ()
+    //{
+    //    TextField tf = new ()
+    //    {
+    //        Text = "item 1",
+    //        Width = 10,
+    //        Height = 1
+    //    };
+
+
+    //    ListView listView = new ()
+    //    {
+    //        Source = new ListWrapper<string> (["item 1", "item 2", "item 3", "item 4", "item 5"]),
+    //    };
+
+
+    //    MenuBarItem? menuBarItem = new ($"{Glyphs.DownArrow}",  )
+    //    {
+    //        MarginThickness = Thickness.Empty
+    //    };
+
+    //    menuBarItem.PopoverMenuOpenChanged += (s, e) =>
+    //    {
+    //        if (e.Value && s is MenuBarItem sender)
+    //        {
+    //            sender.PopoverMenu!.Root.X = tf.FrameToScreen ().X;
+    //            sender.PopoverMenu.Root.Width = tf.Width + sender.Width;
+    //            // Find the subview of Root whos Text matches tf.Text and setfocus to it
+    //            var menuItemToSelect = sender.PopoverMenu.Root.SubViews.OfType<MenuItem> ().FirstOrDefault (mi => mi.Title == tf.Text.ToString ());
+    //            menuItemToSelect?.SetFocus ();
+    //        }
+    //    };
+
+
+    //    var mb = new MenuBar ([menuBarItem])
+    //    {
+    //        CanFocus = true,
+    //        Width = Dim.Auto (),
+    //        Y = Pos.Top (tf),
+    //        X = Pos.Right (tf)
+    //    };
+
+    //    // HACKS required to make this work:
+    //    mb.Accepted += (s, e) =>
+    //    {
+    //        // BUG: This does not select menu item 0
+    //        // Instead what happens is the first keystroke the user presses
+    //        // gets swallowed and focus is moved to 0.  Result is that you have
+    //        // to press down arrow twice to select first menu item and/or have to
+    //        // press Tab twice to move focus back to TextField
+    //        mb.OpenMenu ();
+    //    };
+
+    //    View superView = new ()
+    //    {
+    //        CanFocus = true,
+    //        Height = Dim.Auto (),
+    //        Width = Dim.Auto ()
+    //    };
+    //    superView.Add (tf, mb);
+
+    //    return superView;
+
+    //}
 }

+ 34 - 0
Terminal.Gui/App/PopoverBaseImpl.cs

@@ -121,8 +121,42 @@ public abstract class PopoverBaseImpl : View, IPopover
             {
                 App?.Navigation?.SetFocused (App?.TopRunnableView?.MostFocused);
             }
+
+            App?.TopRunnableView?.SetNeedsDraw ();
         }
 
         return ret;
     }
+
+    ///// <summary>
+    /////     Locates the popover menu at <paramref name="idealScreenPosition"/>. The actual position of the menu will be
+    /////     adjusted to
+    /////     ensure the menu fully fits on the screen, and the mouse cursor is over the first cell of the
+    /////     first MenuItem (if possible).
+    ///// </summary>
+    ///// <param name="idealScreenPosition">If <see langword="null"/>, the current mouse position will be used.</param>
+    //public void SetPosition (Point? idealScreenPosition = null)
+    //{
+    //    idealScreenPosition ??= App?.Mouse.LastMousePosition;
+
+    //    if (idealScreenPosition is null)
+    //    {
+    //        return;
+    //    }
+
+    //    Point pos = idealScreenPosition.Value;
+
+    //    if (!Root.IsInitialized)
+    //    {
+    //        Root.App ??= App;
+    //        Root.BeginInit ();
+    //        Root.EndInit ();
+    //        Root.Layout ();
+    //    }
+
+    //    pos = GetMostVisibleLocationForSubMenu (Root, pos);
+
+    //    Root.X = pos.X;
+    //    Root.Y = pos.Y;
+    //}
 }

+ 95 - 0
Terminal.Gui/App/PopoverWrapper.cs

@@ -0,0 +1,95 @@
+namespace Terminal.Gui.App;
+
+/// <summary>
+///     Wraps any <see cref="View"/> to make it a popover, similar to how
+///     <see cref="RunnableWrapper{TView, TResult}"/> wraps views to make them runnable.
+/// </summary>
+/// <typeparam name="TView">The type of view being wrapped.</typeparam>
+/// <remarks>
+///     <para>
+///         This class enables any View to be shown as a popover with
+///         <see cref="ApplicationPopover.Show"/>
+///         without requiring the View to implement <see cref="IPopover"/> or derive from
+///         <see cref="PopoverBaseImpl"/>.
+///     </para>
+///     <para>
+///         The wrapper automatically handles:
+///         <list type="bullet">
+///             <item>Setting proper viewport settings (transparent, transparent mouse)</item>
+///             <item>Configuring focus behavior</item>
+///             <item>Handling the quit command to hide the popover</item>
+///             <item>Sizing to fill the screen by default</item>
+///         </list>
+///     </para>
+///     <para>
+///         Use <see cref="ViewPopoverExtensions.AsPopover{TView}"/> for a fluent API approach.
+///     </para>
+///     <example>
+///         <code>
+/// // Wrap a custom view to make it a popover
+/// var myView = new View
+/// {
+///     X = Pos.Center (),
+///     Y = Pos.Center (),
+///     Width = 40,
+///     Height = 10,
+///     BorderStyle = LineStyle.Single
+/// };
+/// myView.Add (new Label { Text = "Hello Popover!" });
+/// 
+/// var popover = new PopoverWrapper&lt;View&gt; { WrappedView = myView };
+/// app.Popover.Register (popover);
+/// app.Popover.Show (popover);
+/// </code>
+///     </example>
+/// </remarks>
+public class PopoverWrapper<TView> : PopoverBaseImpl where TView : View
+{
+    /// <summary>
+    ///     Initializes a new instance of <see cref="PopoverWrapper{TView}"/>.
+    /// </summary>
+    public PopoverWrapper ()
+    {
+        Id = "popoverWrapper";
+        Width = Dim.Auto ();
+        Height = Dim.Auto ();
+    }
+
+    private TView? _wrappedView;
+
+    /// <summary>
+    ///     Gets or sets the wrapped view that is being made into a popover.
+    /// </summary>
+    /// <remarks>
+    ///     <para>
+    ///         This property must be set before the wrapper is initialized.
+    ///         Access this property to interact with the original view or configure its behavior.
+    ///     </para>
+    /// </remarks>
+    /// <exception cref="InvalidOperationException">Thrown if the property is set after initialization.</exception>
+    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;
+        }
+    }
+
+    /// <inheritdoc/>
+    public override void EndInit ()
+    {
+        base.EndInit ();
+
+        // Add the wrapped view as a subview after initialization
+        if (_wrappedView is { })
+        {
+            Add (_wrappedView);
+        }
+    }
+}

+ 60 - 0
Terminal.Gui/App/ViewPopoverExtensions.cs

@@ -0,0 +1,60 @@
+namespace Terminal.Gui.App;
+
+/// <summary>
+///     Extension methods for making any <see cref="View"/> into a popover.
+/// </summary>
+/// <remarks>
+///     These extensions provide a fluent API for wrapping views in <see cref="PopoverWrapper{TView}"/>,
+///     enabling any View to be shown as a popover without implementing <see cref="IPopover"/>.
+/// </remarks>
+public static class ViewPopoverExtensions
+{
+    /// <summary>
+    ///     Converts any View into a popover.
+    /// </summary>
+    /// <typeparam name="TView">The type of view to make into a popover.</typeparam>
+    /// <param name="view">The view to wrap. Cannot be null.</param>
+    /// <returns>A <see cref="PopoverWrapper{TView}"/> that wraps the view.</returns>
+    /// <exception cref="ArgumentNullException">Thrown if <paramref name="view"/> is null.</exception>
+    /// <remarks>
+    ///     <para>
+    ///         This method wraps the view in a <see cref="PopoverWrapper{TView}"/> which automatically
+    ///         handles popover behavior including transparency, focus, and quit key handling.
+    ///     </para>
+    ///     <para>
+    ///         After creating the wrapper, register it with <see cref="ApplicationPopover.Register"/>
+    ///         and show it with <see cref="ApplicationPopover.Show"/>.
+    ///     </para>
+    /// </remarks>
+    /// <example>
+    ///     <code>
+    /// // Make a custom view into a popover
+    /// var myView = new View
+    /// {
+    ///     X = Pos.Center (),
+    ///     Y = Pos.Center (),
+    ///     Width = 40,
+    ///     Height = 10,
+    ///     BorderStyle = LineStyle.Single
+    /// };
+    /// myView.Add (new Label { Text = "Hello Popover!" });
+    /// 
+    /// var popover = myView.AsPopover();
+    /// app.Popover.Register (popover);
+    /// app.Popover.Show (popover);
+    /// 
+    /// // The wrapped view can still be accessed
+    /// Console.WriteLine ($"View id: {popover.WrappedView.Id}");
+    /// </code>
+    /// </example>
+    public static PopoverWrapper<TView> AsPopover<TView> (this TView view)
+        where TView : View
+    {
+        if (view is null)
+        {
+            throw new ArgumentNullException (nameof (view));
+        }
+
+        return new PopoverWrapper<TView> { WrappedView = view };
+    }
+}

+ 31 - 7
Terminal.Gui/Views/Shortcut.cs

@@ -190,8 +190,32 @@ public class Shortcut : View, IOrientation, IDesignable
         SetRelativeLayout (SuperView?.GetContentSize () ?? screenSize);
     }
 
-    // TODO: Enable setting of the margin thickness
-    private Thickness GetMarginThickness () => new (1, 0, 1, 0);
+    private Thickness _marginThickness = new (1, 0, 1, 0);
+
+    /// <summary>
+    ///     Gets or sets the margin thickness applied to the CommandView, HelpView, and KeyView.
+    ///     The default is (1,0,1,0).
+    /// </summary>
+    public Thickness MarginThickness
+    {
+        get => _marginThickness;
+        set
+        {
+            _marginThickness = value;
+            if (CommandView.Margin is { })
+            {
+                CommandView.Margin!.Thickness = value;
+            }
+            if (HelpView.Margin is { })
+            {
+                HelpView.Margin!.Thickness = value;
+            }
+            if (KeyView.Margin is { })
+            {
+                KeyView.Margin!.Thickness = value;
+            }
+        }
+    }
 
     // When layout starts, we need to adjust the layout of the HelpView and KeyView
     /// <inheritdoc/>
@@ -212,7 +236,7 @@ public class Shortcut : View, IOrientation, IDesignable
 
         if (_maxHelpWidth < 3)
         {
-            Thickness t = GetMarginThickness ();
+            Thickness t = MarginThickness;
 
             switch (_maxHelpWidth)
             {
@@ -234,7 +258,7 @@ public class Shortcut : View, IOrientation, IDesignable
         else
         {
             // Reset to default
-            HelpView.Margin!.Thickness = GetMarginThickness ();
+            HelpView.Margin!.Thickness = MarginThickness;
         }
     }
 
@@ -484,7 +508,7 @@ public class Shortcut : View, IOrientation, IDesignable
     {
         if (CommandView.Margin is { })
         {
-            CommandView.Margin!.Thickness = GetMarginThickness ();
+            CommandView.Margin!.Thickness = MarginThickness;
 
             // strip off ViewportSettings.TransparentMouse
             CommandView.Margin!.ViewportSettings &= ~ViewportSettingsFlags.TransparentMouse;
@@ -550,7 +574,7 @@ public class Shortcut : View, IOrientation, IDesignable
     {
         if (HelpView.Margin is { })
         {
-            HelpView.Margin!.Thickness = GetMarginThickness ();
+            HelpView.Margin!.Thickness = MarginThickness;
 
             // strip off ViewportSettings.TransparentMouse
             HelpView.Margin!.ViewportSettings &= ~ViewportSettingsFlags.TransparentMouse;
@@ -687,7 +711,7 @@ public class Shortcut : View, IOrientation, IDesignable
     {
         if (KeyView.Margin is { })
         {
-            KeyView.Margin!.Thickness = GetMarginThickness ();
+            KeyView.Margin!.Thickness = MarginThickness;
 
             // strip off ViewportSettings.TransparentMouse
             KeyView.Margin!.ViewportSettings &= ~ViewportSettingsFlags.TransparentMouse;

+ 6 - 0
Terminal.sln

@@ -127,6 +127,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FluentExample", "Examples\F
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RunnableWrapperExample", "Examples\RunnableWrapperExample\RunnableWrapperExample.csproj", "{26FDEE3C-9D1F-79A6-F48F-D0944C7F09F8}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PopoverWrapperExample", "Examples\PopoverWrapperExample\PopoverWrapperExample.csproj", "{4DFA7371-86D5-B970-9535-368FE1393D90}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -209,6 +211,10 @@ Global
 		{26FDEE3C-9D1F-79A6-F48F-D0944C7F09F8}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{26FDEE3C-9D1F-79A6-F48F-D0944C7F09F8}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{26FDEE3C-9D1F-79A6-F48F-D0944C7F09F8}.Release|Any CPU.Build.0 = Release|Any CPU
+		{4DFA7371-86D5-B970-9535-368FE1393D90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{4DFA7371-86D5-B970-9535-368FE1393D90}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{4DFA7371-86D5-B970-9535-368FE1393D90}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{4DFA7371-86D5-B970-9535-368FE1393D90}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE

+ 164 - 0
Tests/UnitTestsParallelizable/Application/PopoverWrapperTests.cs

@@ -0,0 +1,164 @@
+using Terminal.Gui.App;
+using Terminal.Gui.Views;
+
+namespace ApplicationTests;
+
+public class PopoverWrapperTests
+{
+    [Fact]
+    public void Constructor_SetsDefaults ()
+    {
+        var wrapper = new PopoverWrapper<View> { WrappedView = new View () };
+
+        Assert.Equal ("popoverWrapper", wrapper.Id);
+        Assert.True (wrapper.CanFocus);
+        Assert.Equal (Dim.Fill (), wrapper.Width);
+        Assert.Equal (Dim.Fill (), wrapper.Height);
+        Assert.True (wrapper.ViewportSettings.HasFlag (ViewportSettingsFlags.Transparent));
+        Assert.True (wrapper.ViewportSettings.HasFlag (ViewportSettingsFlags.TransparentMouse));
+    }
+
+    [Fact]
+    public void WrappedView_CanBeSet ()
+    {
+        var view = new View { Id = "testView" };
+        var wrapper = new PopoverWrapper<View> { WrappedView = view };
+
+        Assert.Same (view, wrapper.WrappedView);
+        Assert.Equal ("testView", wrapper.WrappedView.Id);
+    }
+
+    [Fact]
+    public void EndInit_AddsWrappedViewAsSubview ()
+    {
+        var view = new View { Id = "wrapped" };
+        var wrapper = new PopoverWrapper<View> { WrappedView = view };
+
+        wrapper.BeginInit ();
+        wrapper.EndInit ();
+
+        Assert.Contains (view, wrapper.SubViews);
+        Assert.Same (wrapper, view.SuperView);
+    }
+
+    [Fact]
+    public void CanBeRegisteredAndShown ()
+    {
+        var view = new View
+        {
+            X = Pos.Center (),
+            Y = Pos.Center (),
+            Width = 20,
+            Height = 10
+        };
+
+        var wrapper = new PopoverWrapper<View> { WrappedView = view };
+        var popoverManager = new ApplicationPopover ();
+
+        popoverManager.Register (wrapper);
+        Assert.Contains (wrapper, popoverManager.Popovers);
+
+        popoverManager.Show (wrapper);
+        Assert.Equal (wrapper, popoverManager.GetActivePopover ());
+        Assert.True (wrapper.Visible);
+    }
+
+    [Fact]
+    public void QuitCommand_HidesPopover ()
+    {
+        var view = new View ();
+        var wrapper = new PopoverWrapper<View> { WrappedView = view };
+        var popoverManager = new ApplicationPopover ();
+
+        popoverManager.Register (wrapper);
+        popoverManager.Show (wrapper);
+
+        Assert.True (wrapper.Visible);
+
+        wrapper.InvokeCommand (Command.Quit);
+
+        Assert.False (wrapper.Visible);
+    }
+
+    [Fact]
+    public void AsPopover_Extension_CreatesWrapper ()
+    {
+        var view = new View { Id = "testView" };
+
+        PopoverWrapper<View> wrapper = view.AsPopover ();
+
+        Assert.NotNull (wrapper);
+        Assert.Same (view, wrapper.WrappedView);
+    }
+
+    [Fact]
+    public void AsPopover_Extension_ThrowsIfViewIsNull ()
+    {
+        View? view = null;
+
+        Assert.Throws<ArgumentNullException> (() => view!.AsPopover ());
+    }
+
+    [Fact]
+    public void WrappedView_ReceivesInput ()
+    {
+        var textField = new TextField { Width = 20 };
+        var wrapper = new PopoverWrapper<TextField> { WrappedView = textField };
+
+        wrapper.BeginInit ();
+        wrapper.EndInit ();
+
+        var popoverManager = new ApplicationPopover ();
+        popoverManager.Register (wrapper);
+        popoverManager.Show (wrapper);
+
+        Assert.True (wrapper.Visible);
+        Assert.Contains (textField, wrapper.SubViews);
+    }
+
+    [Fact]
+    public void Multiple_Types_CanBeWrapped ()
+    {
+        var label = new Label { Text = "Test" };
+        var labelWrapper = new PopoverWrapper<Label> { WrappedView = label };
+
+        var button = new Button { Title = "Click" };
+        var buttonWrapper = new PopoverWrapper<Button> { WrappedView = button };
+
+        var listView = new ListView ();
+        var listViewWrapper = new PopoverWrapper<ListView> { WrappedView = listView };
+
+        Assert.Same (label, labelWrapper.WrappedView);
+        Assert.Same (button, buttonWrapper.WrappedView);
+        Assert.Same (listView, listViewWrapper.WrappedView);
+    }
+
+    [Fact]
+    public void Current_Property_CanBeSetAndGet ()
+    {
+        var view = new View ();
+        var wrapper = new PopoverWrapper<View> { WrappedView = view };
+        var runnable = new Runnable ();
+
+        wrapper.Current = runnable;
+
+        Assert.Same (runnable, wrapper.Current);
+    }
+
+    [Fact]
+    public void Disposed_Wrapper_DisposesWrappedView ()
+    {
+        var view = new View ();
+        var wrapper = new PopoverWrapper<View> { WrappedView = view };
+
+        wrapper.BeginInit ();
+        wrapper.EndInit ();
+
+        bool viewDisposed = false;
+        view.Disposing += (s, e) => viewDisposed = true;
+
+        wrapper.Dispose ();
+
+        Assert.True (viewDisposed);
+    }
+}