diff --git a/BACKEND_IMPLEMENTATION_CHECKLIST.MD b/BACKEND_IMPLEMENTATION_CHECKLIST.MD index 625319a..80c4d04 100644 --- a/BACKEND_IMPLEMENTATION_CHECKLIST.MD +++ b/BACKEND_IMPLEMENTATION_CHECKLIST.MD @@ -43,7 +43,7 @@ Implementation status of the .NET MAUI backend for Linux using GTK4. Adapted fro | Control | Status | Notes | |---------|--------|-------| | ✅ **Application** | Done | App lifecycle via `GtkMauiApplication`, `Gtk.Application` integration | -| ✅ **Window** | Done | Title, Width/Height mapping, default size 1024×768, min 800×600, Activated/Deactivated events, modal push/pop. X/Y positioning disabled (Wayland/X11 compositor constraints) | +| ✅ **Window** | Done | Title, Width/Height mapping, default size 1024×768, min 800×600, Activated/Deactivated events, native modal dialog windows via `PushModalAsync` with sizing options (`GtkPage` attached properties). X/Y positioning disabled (Wayland/X11 compositor constraints) | | ✅ **Multi-Window** | Done | `Application.OpenWindow`/`CloseWindow`, `Application.Windows` collection tracking, window lifecycle events (Creating, Destroying, Activated, Deactivated) | --- @@ -144,7 +144,7 @@ Implementation status of the .NET MAUI backend for Linux using GTK4. Adapted fro | ✅ **DisplayAlert** | Done | Custom GTK Window dialog with Title, message, accept/cancel buttons | | ✅ **DisplayActionSheet** | Done | Dialog with action buttons, destructive button styling, cancel | | ✅ **DisplayPromptAsync** | Done | Text entry dialog with placeholder, initial value, validation | -| ✅ **Modal overlay** | Done | GTK modal window with transient-for parent | +| ✅ **Modal Pages** | Done | `PushModalAsync` uses native GTK4 modal `Gtk.Window` (default). `GtkPage` attached properties: `ModalPresentationStyle` (Dialog/Inline), `ModalSizesToContent`, `ModalWidth`/`ModalHeight`, `ModalMinWidth`/`ModalMinHeight` | --- diff --git a/README.md b/README.md index 20c0bd6..ff783c8 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ https://github.com/user-attachments/assets/70f2a910-94b3-437c-945a-6b71223c5cd3 - **Font icons** — Embedded font registration with fontconfig/Pango, FontImageSource rendering via Cairo. FontAwesome and custom icon fonts work out of the box. - **FormattedText** — Rich text via Pango markup: Span colors, fonts, sizes, bold/italic, underline/strikethrough, character spacing. - **Alerts & Dialogs** — `DisplayAlert`, `DisplayActionSheet`, `DisplayPromptAsync` via native GTK4 modal windows. +- **Modal Pages** — `PushModalAsync` presents pages as native GTK4 dialog windows by default, with attached properties for custom sizing, content-fit sizing, and inline (legacy) presentation via `GtkPage`. - **Brushes & Gradients** — SolidColorBrush, LinearGradientBrush, RadialGradientBrush via CSS gradients. - **Tooltips & Context Menus** — `ToolTipProperties.Text` and `ContextFlyout` via `Gtk.PopoverMenu`. - **Theming** — Automatic light/dark theme detection via `GtkThemeManager`. @@ -85,7 +86,7 @@ https://github.com/user-attachments/assets/70f2a910-94b3-437c-945a-6b71223c5cd3 | Input Controls | 100% | Picker, DatePicker, TimePicker, SearchBar | | Collection Controls | 100% | Virtualized CollectionView, ListView, TableView, CarouselView, SwipeView | | Navigation & Routing | 100% | Push/pop, Shell routes, query parameters | -| Alerts & Dialogs | 100% | All three dialog types + modal overlay | +| Alerts & Dialogs | 100% | All three dialog types + native modal dialog windows | | Gesture Recognizers | 100% | All 5 gesture types | | Graphics & Shapes | 100% | GraphicsView + all 6 shape types | | Font Management | 100% | Registrar, manager, FontImageSource, named sizes | diff --git a/samples/Platform.Maui.Linux.Gtk4.Sample/App.cs b/samples/Platform.Maui.Linux.Gtk4.Sample/App.cs index a5493c7..9fc9f59 100644 --- a/samples/Platform.Maui.Linux.Gtk4.Sample/App.cs +++ b/samples/Platform.Maui.Linux.Gtk4.Sample/App.cs @@ -46,6 +46,7 @@ private readonly (string name, Func factory)[] _pages = ("📂 FlyoutPage", () => new FlyoutPageDemo()), ("🐚 Shell Navigation", () => new ShellDemoPage()), ("🧬 ControlTemplate", () => new ControlTemplatePage()), + ("🪟 Modal Pages", () => new ModalDemoPage()), ("🪟 Multi-Window", () => new MultiWindowPage()), ("🎨 Theme", () => new ThemePage()), ]; diff --git a/samples/Platform.Maui.Linux.Gtk4.Sample/Pages/ModalDemoPage.cs b/samples/Platform.Maui.Linux.Gtk4.Sample/Pages/ModalDemoPage.cs new file mode 100644 index 0000000..72d7678 --- /dev/null +++ b/samples/Platform.Maui.Linux.Gtk4.Sample/Pages/ModalDemoPage.cs @@ -0,0 +1,225 @@ +using Microsoft.Maui.Controls; +using Microsoft.Maui.Graphics; +using Platform.Maui.Linux.Gtk4.Platform; + +namespace Platform.Maui.Linux.Gtk4.Sample.Pages; + +/// +/// Demonstrates PushModalAsync with native GTK4 dialog windows (default), +/// custom sizing, content-sized dialogs, and the inline fallback. +/// +class ModalDemoPage : ContentPage +{ + private readonly Label _statusLabel; + + public ModalDemoPage() + { + Title = "Modal Pages"; + + _statusLabel = new Label + { + Text = "Tap a button to push a modal page.", + HorizontalOptions = LayoutOptions.Center, + Margin = new Thickness(0, 0, 0, 20), + }; + + // --- Default (full-size dialog) --- + var nativeModalBtn = new Button + { + Text = "Dialog Modal (Default)", + AutomationId = "btnNativeModal", + Margin = new Thickness(0, 4), + }; + nativeModalBtn.Clicked += async (s, e) => + { + var modal = CreateModalContent("Default Dialog", + "Full-size native GTK4 modal window.\n" + + "Matches the parent window dimensions."); + await Navigation.PushModalAsync(modal); + _statusLabel.Text = "Default dialog was dismissed."; + }; + + // --- Custom size (400×300) --- + var customSizeBtn = new Button + { + Text = "Dialog Modal (400×300)", + AutomationId = "btnCustomSize", + Margin = new Thickness(0, 4), + }; + customSizeBtn.Clicked += async (s, e) => + { + var modal = CreateModalContent("Small Dialog", + "Custom sized dialog (400×300).\nResizable with min constraints."); + GtkPage.SetModalWidth(modal, 400); + GtkPage.SetModalHeight(modal, 300); + GtkPage.SetModalMinWidth(modal, 300); + GtkPage.SetModalMinHeight(modal, 200); + await Navigation.PushModalAsync(modal); + _statusLabel.Text = "Custom size dialog was dismissed."; + }; + + // --- Sizes to content --- + var contentSizeBtn = new Button + { + Text = "Dialog Modal (Sizes to Content)", + AutomationId = "btnContentSize", + Margin = new Thickness(0, 4), + }; + contentSizeBtn.Clicked += async (s, e) => + { + var modal = CreateModalContent("Content-Sized", + "This dialog measured its content\nand sized itself to fit."); + GtkPage.SetModalSizesToContent(modal, true); + GtkPage.SetModalMinWidth(modal, 250); + GtkPage.SetModalMinHeight(modal, 150); + await Navigation.PushModalAsync(modal); + _statusLabel.Text = "Content-sized dialog was dismissed."; + }; + + // --- Inline (legacy) --- + var inlineModalBtn = new Button + { + Text = "Inline Modal (Legacy)", + AutomationId = "btnInlineModal", + Margin = new Thickness(0, 4), + }; + inlineModalBtn.Clicked += async (s, e) => + { + var modal = CreateModalContent("Inline Modal", + "Inline presentation style.\n" + + "Hides current content and shows\nthe modal in its place."); + GtkPage.SetModalPresentationStyle(modal, GtkModalPresentationStyle.Inline); + await Navigation.PushModalAsync(modal); + _statusLabel.Text = "Inline modal was dismissed."; + }; + + // --- Stacked --- + var stackedModalBtn = new Button + { + Text = "Push Two Stacked Modals", + AutomationId = "btnStackedModal", + Margin = new Thickness(0, 4), + }; + stackedModalBtn.Clicked += async (s, e) => + { + var first = CreateModalContent("First Modal", + "This is the first modal dialog.\n" + + "Tap 'Push Another' to stack a second modal."); + + var pushAnotherBtn = new Button + { + Text = "Push Another Modal", + AutomationId = "btnPushAnother", + BackgroundColor = Colors.DarkSlateBlue, + TextColor = Colors.White, + Margin = new Thickness(0, 8), + }; + pushAnotherBtn.Clicked += async (s2, e2) => + { + var second = CreateModalContent("Second Modal", + "Stacked modal dialog.\nDismiss to return to the first."); + GtkPage.SetModalWidth(second, 400); + GtkPage.SetModalHeight(second, 300); + await Navigation.PushModalAsync(second); + }; + + if (first.Content is VerticalStackLayout vsl) + vsl.Children.Insert(vsl.Children.Count - 1, pushAnotherBtn); + + await Navigation.PushModalAsync(first); + _statusLabel.Text = "Stacked modals were dismissed."; + }; + + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Padding = new Thickness(20), + Spacing = 8, + Children = + { + new Label + { + Text = "Modal Pages Demo", + FontSize = 22, + FontAttributes = FontAttributes.Bold, + Margin = new Thickness(0, 0, 0, 8), + }, + _statusLabel, + + new Label { Text = "Dialog Presentations", FontSize = 16, FontAttributes = FontAttributes.Bold, TextColor = Colors.CornflowerBlue }, + nativeModalBtn, + customSizeBtn, + contentSizeBtn, + + new Border { HeightRequest = 1, BackgroundColor = Colors.Gray, Opacity = 0.3, StrokeThickness = 0 }, + + new Label { Text = "Other Styles", FontSize = 16, FontAttributes = FontAttributes.Bold, TextColor = Colors.CornflowerBlue }, + inlineModalBtn, + stackedModalBtn, + + new Border { HeightRequest = 1, BackgroundColor = Colors.Gray, Opacity = 0.3, StrokeThickness = 0 }, + + new Label + { + Text = "• Default opens a full-size native GTK4 dialog\n" + + "• Custom size sets explicit dialog dimensions\n" + + "• Content-sized measures the MAUI content\n" + + "• Inline uses the legacy in-window overlay", + FontSize = 12, + TextColor = Colors.Gray, + }, + } + } + }; + } + + private static ContentPage CreateModalContent(string title, string description) + { + var dismissBtn = new Button + { + Text = "Dismiss", + AutomationId = "btnDismissModal", + BackgroundColor = Colors.Tomato, + TextColor = Colors.White, + Margin = new Thickness(0, 16), + }; + + var page = new ContentPage + { + Title = title, + BackgroundColor = Color.FromArgb("#F5F5F5"), + Content = new VerticalStackLayout + { + Padding = new Thickness(30), + Spacing = 12, + VerticalOptions = LayoutOptions.Center, + Children = + { + new Label + { + Text = title, + FontSize = 24, + FontAttributes = FontAttributes.Bold, + HorizontalOptions = LayoutOptions.Center, + }, + new Label + { + Text = description, + FontSize = 14, + HorizontalOptions = LayoutOptions.Center, + HorizontalTextAlignment = TextAlignment.Center, + }, + dismissBtn, + } + } + }; + + dismissBtn.Clicked += async (s, e) => + { + await page.Navigation.PopModalAsync(); + }; + + return page; + } +} diff --git a/src/Platform.Maui.Linux.Gtk4/Handlers/WindowHandler.cs b/src/Platform.Maui.Linux.Gtk4/Handlers/WindowHandler.cs index 5a39ae7..fd2f0ef 100644 --- a/src/Platform.Maui.Linux.Gtk4/Handlers/WindowHandler.cs +++ b/src/Platform.Maui.Linux.Gtk4/Handlers/WindowHandler.cs @@ -24,6 +24,8 @@ public class WindowHandler : ElementHandler { }; + private readonly Dictionary _modalDialogs = new(); + public WindowHandler() : base(Mapper, CommandMapper) { } @@ -65,6 +67,13 @@ protected override void DisconnectHandler(Gtk.Window platformView) mauiWindow.ModalPopped -= OnModalPopped; } + foreach (var dialog in _modalDialogs.Values) + { + dialog.SetChild(null); + dialog.Close(); + } + _modalDialogs.Clear(); + platformView.OnCloseRequest -= OnCloseRequest; platformView.OnNotify -= OnWindowNotify; base.DisconnectHandler(platformView); @@ -98,17 +107,144 @@ private void OnModalPushed(object? sender, ModalPushedEventArgs e) { if (MauiContext == null || PlatformView == null) return; - var container = PlatformView.GetChild() as WindowRootViewContainer; - if (container == null) return; + var style = e.Modal is Page p + ? GtkPage.GetModalPresentationStyle(p) + : GtkModalPresentationStyle.Dialog; + + if (style == GtkModalPresentationStyle.Inline) + { + // Inline: hide current content and show modal within the same window + var container = PlatformView.GetChild() as WindowRootViewContainer; + if (container == null) return; + + var platformContent = (Gtk.Widget)e.Modal.ToPlatform(MauiContext); + container.PushModal(platformContent); + } + else + { + // Native GTK4 modal dialog window (default) + var platformContent = (Gtk.Widget)e.Modal.ToPlatform(MauiContext); + + var dialog = new Gtk.Window(); + dialog.SetModal(true); + dialog.SetTransientFor(PlatformView); + dialog.SetTitle((e.Modal as Page)?.Title ?? string.Empty); + + var (width, height) = ComputeDialogSize(e.Modal as Page); + dialog.SetDefaultSize((int)width, (int)height); + + // Apply minimum size constraints + if (e.Modal is Page modalPage2) + { + var minW = GtkPage.GetModalMinWidth(modalPage2); + var minH = GtkPage.GetModalMinHeight(modalPage2); + if (minW > 0 || minH > 0) + dialog.SetSizeRequest(minW > 0 ? (int)minW : -1, minH > 0 ? (int)minH : -1); + } + + var app = PlatformView.GetApplication(); + if (app != null) + dialog.SetApplication(app); + + platformContent.SetVexpand(true); + platformContent.SetHexpand(true); + dialog.SetChild(platformContent); + + if (e.Modal is Page modalPage) + { + _modalDialogs[modalPage] = dialog; + + dialog.OnCloseRequest += (_, _) => + { + // Remove from tracking first; if already gone this is a + // programmatic close from OnModalPopped — just allow it. + if (!_modalDialogs.Remove(modalPage)) + return false; + + // User clicked X — let GTK close the window immediately + // and tell MAUI to pop the modal. + if (VirtualView is Microsoft.Maui.Controls.Window mauiWindow) + _ = mauiWindow.Navigation.PopModalAsync(); + return false; + }; + } + + dialog.Present(); + } + } + + private (double width, double height) ComputeDialogSize(Page? page) + { + PlatformView!.GetDefaultSize(out var pw, out var ph); + double parentWidth = pw > 0 ? pw : 800; + double parentHeight = ph > 0 ? ph : 600; + + if (page == null) + return (parentWidth, parentHeight); + + var requestedWidth = GtkPage.GetModalWidth(page); + var requestedHeight = GtkPage.GetModalHeight(page); + var sizesToContent = GtkPage.GetModalSizesToContent(page); + + double width = parentWidth; + double height = parentHeight; + + if (requestedWidth > 0) + width = requestedWidth; + + if (requestedHeight > 0) + height = requestedHeight; + + // When sizing to content, measure the page's Content (not the Page itself, + // since Page always fills available space). + if (sizesToContent && (requestedWidth <= 0 || requestedHeight <= 0)) + { + var contentView = (page as ContentPage)?.Content as IView; + if (contentView != null) + { + var measured = contentView.Measure( + double.PositiveInfinity, + double.PositiveInfinity); + + var padding = page.Padding; + var contentWidth = measured.Width + padding.Left + padding.Right; + var contentHeight = measured.Height + padding.Top + padding.Bottom; + + if (requestedWidth <= 0) + width = contentWidth; + if (requestedHeight <= 0) + height = contentHeight; + } + } + + // Apply min size constraints + var minWidth = GtkPage.GetModalMinWidth(page); + var minHeight = GtkPage.GetModalMinHeight(page); + if (minWidth > 0 && width < minWidth) width = minWidth; + if (minHeight > 0 && height < minHeight) height = minHeight; - var platformContent = (Gtk.Widget)e.Modal.ToPlatform(MauiContext); - container.PushModal(platformContent); + // Don't exceed parent window size + if (width > parentWidth) width = parentWidth; + if (height > parentHeight) height = parentHeight; + + return (width, height); } private void OnModalPopped(object? sender, ModalPoppedEventArgs e) { - var container = PlatformView?.GetChild() as WindowRootViewContainer; - container?.PopModal(); + if (e.Modal is Page page && _modalDialogs.Remove(page, out var dialog)) + { + // Programmatic pop — close the native dialog window + dialog.Close(); + } + else if (e.Modal is not Page mp + || GtkPage.GetModalPresentationStyle(mp) == GtkModalPresentationStyle.Inline) + { + // Inline modal — pop from the container + var container = PlatformView?.GetChild() as WindowRootViewContainer; + container?.PopModal(); + } + // else: user-initiated dialog close — already closed by GTK via OnCloseRequest } public static void MapTitle(WindowHandler handler, IWindow window) diff --git a/src/Platform.Maui.Linux.Gtk4/Platform/GtkPage.cs b/src/Platform.Maui.Linux.Gtk4/Platform/GtkPage.cs new file mode 100644 index 0000000..396ce96 --- /dev/null +++ b/src/Platform.Maui.Linux.Gtk4/Platform/GtkPage.cs @@ -0,0 +1,149 @@ +using Microsoft.Maui.Controls; + +namespace Platform.Maui.Linux.Gtk4.Platform; + +/// +/// Controls how a page is presented when pushed modally via PushModalAsync. +/// +public enum GtkModalPresentationStyle +{ + /// + /// Present as a native GTK4 modal window (Gtk.Window with SetModal/SetTransientFor). + /// + Dialog = 0, + + /// + /// Present inline within the main window, hiding the current content (legacy behavior). + /// + Inline = 1, +} + +/// +/// Attached properties for configuring GTK-specific page behavior. +/// +/// +/// +/// // Native dialog with custom size +/// var page = new MyModalPage(); +/// GtkPage.SetModalWidth(page, 600); +/// GtkPage.SetModalHeight(page, 400); +/// await Navigation.PushModalAsync(page); +/// +/// // Dialog that sizes to its content +/// GtkPage.SetModalSizesToContent(page, true); +/// GtkPage.SetModalMinWidth(page, 300); +/// GtkPage.SetModalMinHeight(page, 200); +/// await Navigation.PushModalAsync(page); +/// +/// // Inline style (old behavior) +/// GtkPage.SetModalPresentationStyle(page, GtkModalPresentationStyle.Inline); +/// await Navigation.PushModalAsync(page); +/// +/// +public static class GtkPage +{ + /// + /// Controls how the page is presented when pushed modally. + /// Defaults to for native GTK4 dialog window. + /// Set to for the inline presentation. + /// + public static readonly BindableProperty ModalPresentationStyleProperty = + BindableProperty.CreateAttached( + "ModalPresentationStyle", + typeof(GtkModalPresentationStyle), + typeof(GtkPage), + GtkModalPresentationStyle.Dialog); + + public static GtkModalPresentationStyle GetModalPresentationStyle(BindableObject obj) + => (GtkModalPresentationStyle)obj.GetValue(ModalPresentationStyleProperty); + + public static void SetModalPresentationStyle(BindableObject obj, GtkModalPresentationStyle value) + => obj.SetValue(ModalPresentationStyleProperty, value); + + /// + /// When true, the dialog measures the page content and sizes to fit. + /// Respects and . + /// Ignored when or are set. + /// Defaults to false (dialog matches parent window size). + /// + public static readonly BindableProperty ModalSizesToContentProperty = + BindableProperty.CreateAttached( + "ModalSizesToContent", + typeof(bool), + typeof(GtkPage), + false); + + public static bool GetModalSizesToContent(BindableObject obj) + => (bool)obj.GetValue(ModalSizesToContentProperty); + + public static void SetModalSizesToContent(BindableObject obj, bool value) + => obj.SetValue(ModalSizesToContentProperty, value); + + /// + /// Requested width for the modal dialog. When set to -1 (default), the dialog + /// matches the parent window width. Only applies to Dialog presentation style. + /// + public static readonly BindableProperty ModalWidthProperty = + BindableProperty.CreateAttached( + "ModalWidth", + typeof(double), + typeof(GtkPage), + -1d); + + public static double GetModalWidth(BindableObject obj) + => (double)obj.GetValue(ModalWidthProperty); + + public static void SetModalWidth(BindableObject obj, double value) + => obj.SetValue(ModalWidthProperty, value); + + /// + /// Requested height for the modal dialog. When set to -1 (default), the dialog + /// matches the parent window height. Only applies to Dialog presentation style. + /// + public static readonly BindableProperty ModalHeightProperty = + BindableProperty.CreateAttached( + "ModalHeight", + typeof(double), + typeof(GtkPage), + -1d); + + public static double GetModalHeight(BindableObject obj) + => (double)obj.GetValue(ModalHeightProperty); + + public static void SetModalHeight(BindableObject obj, double value) + => obj.SetValue(ModalHeightProperty, value); + + /// + /// Minimum width for the modal dialog. Used when + /// is true, or as the GTK window size request. Defaults to -1 (no minimum). + /// + public static readonly BindableProperty ModalMinWidthProperty = + BindableProperty.CreateAttached( + "ModalMinWidth", + typeof(double), + typeof(GtkPage), + -1d); + + public static double GetModalMinWidth(BindableObject obj) + => (double)obj.GetValue(ModalMinWidthProperty); + + public static void SetModalMinWidth(BindableObject obj, double value) + => obj.SetValue(ModalMinWidthProperty, value); + + /// + /// Minimum height for the modal dialog. Used when + /// is true, or as the GTK window size request. Defaults to -1 (no minimum). + /// + public static readonly BindableProperty ModalMinHeightProperty = + BindableProperty.CreateAttached( + "ModalMinHeight", + typeof(double), + typeof(GtkPage), + -1d); + + public static double GetModalMinHeight(BindableObject obj) + => (double)obj.GetValue(ModalMinHeightProperty); + + public static void SetModalMinHeight(BindableObject obj, double value) + => obj.SetValue(ModalMinHeightProperty, value); +}