From 78e701fd548f893236e0db3c5d98a739007eefed Mon Sep 17 00:00:00 2001 From: Zach Botterman <6074435+popaprozac@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:21:10 -0800 Subject: [PATCH] feat(v3): Modal windows (macOS) (#4839) * modal window * thread safety * update docs * windows support --------- Co-authored-by: Lea Anthony --- .../docs/features/windows/multiple.mdx | 48 ++++++------------- docs/src/content/docs/reference/window.mdx | 25 ++++++++-- v3/UNRELEASED_CHANGELOG.md | 1 + v3/pkg/application/webview_window.go | 17 +++++++ v3/pkg/application/webview_window_android.go | 4 ++ v3/pkg/application/webview_window_darwin.go | 29 +++++++++++ v3/pkg/application/webview_window_ios.go | 4 ++ v3/pkg/application/webview_window_linux.go | 4 ++ v3/pkg/application/webview_window_windows.go | 46 ++++++++++++++++++ v3/pkg/application/window.go | 1 + 10 files changed, 140 insertions(+), 39 deletions(-) diff --git a/docs/src/content/docs/features/windows/multiple.mdx b/docs/src/content/docs/features/windows/multiple.mdx index 41d5a18fe..60694abc9 100644 --- a/docs/src/content/docs/features/windows/multiple.mdx +++ b/docs/src/content/docs/features/windows/multiple.mdx @@ -288,7 +288,7 @@ func CreateToolPalette(app *application.Application) *application.WebviewWindow } ``` -### Pattern 4: Modal dialogs +### Pattern 4: Modal dialogs (macOS only) Child windows that block parent: @@ -303,14 +303,7 @@ func ShowModaldialog(parent *application.WebviewWindow, title string) { Resizable: false, }) - // Disable parent (platform-specific) - parent.SetEnabled(false) - - // Re-enable parent on close - dialog.OnDestroy(func() { - parent.SetEnabled(true) - parent.SetFocus() - }) + parent.AttachModal(dialog) } ``` @@ -359,51 +352,38 @@ func (e *EditorApp) TogglePreview() { ```go childWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: "Child Window", - Parent: parentWindow, }) + +parentWindow.AttachModal(childWindow) ``` **Behaviour:** -- Child closes when parent closes -- Child stays above parent (on some platforms) -- Child minimises with parent (on some platforms) +- Child stays above parent +- Child moves with parent +- Child blocks interaction to parent **Platform support:** -| Feature | macOS | Windows | Linux | -|---------|-------|---------|-------| -| Auto-close | ✅ | ✅ | ⚠️ Varies | -| Stay above | ✅ | ⚠️ Partial | ⚠️ Varies | -| Minimise together | ✅ | ❌ | ⚠️ Varies | +| macOS | Windows | Linux | +|-------|---------|-------| +| ✅ | ❌ | ❌ | ### Modal Behaviour Create modal-like behaviour: ```go -func ShowModal(parent *application.WebviewWindow) { - modal := app.Window.NewWithOptions(application.WebviewWindowOptions{ - Title: "Modal dialog", +func ShowModaldialog(parent *application.WebviewWindow, title string) { + dialog := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: title, Width: 400, Height: 200, - Parent: parent, - AlwaysOnTop: true, }) - // Disable parent interaction - parent.SetEnabled(false) - - // Re-enable on close - modal.OnClose(func() bool { - parent.SetEnabled(true) - parent.SetFocus() - return true - }) + parent.AttachModal(dialog) } ``` -**Note:** True modal behaviour (blocking) varies by platform. - ## Window Lifecycle Management ### Creation Callbacks diff --git a/docs/src/content/docs/reference/window.mdx b/docs/src/content/docs/reference/window.mdx index 346a326e6..c0fc9c686 100644 --- a/docs/src/content/docs/reference/window.mdx +++ b/docs/src/content/docs/reference/window.mdx @@ -597,12 +597,27 @@ if err != nil { } ``` -**Platform support:** -- **macOS**: Full support -- **Windows**: Full support -- **Linux**: Full support +### AttachModal() -**Note:** This triggers the native OS print dialog, allowing the user to select printer settings and print the current window content. +Attaches a second Window as a sheet modal. + +```go +func (w *Window) AttachModal(modalWindow Window) +``` + +**Parameters:** +- `modalWindow` - The window to attach as a modal + +**Platform support:** +- **macOS**: Full support (presents as a sheet) +- **Windows**: No support +- **Linux**: No support + +**Example:** +```go +modalWindow := app.Window.New() +window.AttachModal(modalWindow) +``` ## Platform-Specific Options diff --git a/v3/UNRELEASED_CHANGELOG.md b/v3/UNRELEASED_CHANGELOG.md index 8e4648038..ea616db89 100644 --- a/v3/UNRELEASED_CHANGELOG.md +++ b/v3/UNRELEASED_CHANGELOG.md @@ -17,6 +17,7 @@ After processing, the content will be moved to the main changelog and this file ## Added +- Add support for modal sheets (macOS) ## Changed diff --git a/v3/pkg/application/webview_window.go b/v3/pkg/application/webview_window.go index 6cfa8b821..91909e83e 100644 --- a/v3/pkg/application/webview_window.go +++ b/v3/pkg/application/webview_window.go @@ -113,6 +113,7 @@ type ( setMenu(menu *Menu) snapAssist() setContentProtection(enabled bool) + attachModal(modalWindow *WebviewWindow) } ) @@ -1284,6 +1285,22 @@ func (w *WebviewWindow) NativeWindow() unsafe.Pointer { return w.impl.nativeWindow() } +// AttachModal attaches a modal window to this window, presenting it as a sheet on macOS. +func (w *WebviewWindow) AttachModal(modalWindow Window) { + if w.impl == nil || w.isDestroyed() { + return + } + + modalWebviewWindow, ok := modalWindow.(*WebviewWindow) + if !ok || modalWebviewWindow == nil { + return + } + + InvokeSync(func() { + w.impl.attachModal(modalWebviewWindow) + }) +} + // shouldUnconditionallyClose returns whether the window should close unconditionally func (w *WebviewWindow) shouldUnconditionallyClose() bool { return atomic.LoadUint32(&w.unconditionallyClose) != 0 diff --git a/v3/pkg/application/webview_window_android.go b/v3/pkg/application/webview_window_android.go index ed41a5add..6e139f52c 100644 --- a/v3/pkg/application/webview_window_android.go +++ b/v3/pkg/application/webview_window_android.go @@ -296,6 +296,10 @@ func (w *androidWebviewWindow) nativeWindow() unsafe.Pointer { return nil } +func (w *androidWebviewWindow) attachModal(modalWindow *WebviewWindow) { + // Modal windows are not supported on Android +} + func (w *androidWebviewWindow) on(eventID uint) { // Android event handling } diff --git a/v3/pkg/application/webview_window_darwin.go b/v3/pkg/application/webview_window_darwin.go index 8fe0ed022..14fef21da 100644 --- a/v3/pkg/application/webview_window_darwin.go +++ b/v3/pkg/application/webview_window_darwin.go @@ -331,6 +331,24 @@ void windowZoomOut(void* nsWindow) { } } +// createModalWindow presents a modal window as a sheet attached to the parent window +void createModalWindow(void* parentWindowPtr, void* modalWindowPtr) { + if (parentWindowPtr == NULL || modalWindowPtr == NULL) { + return; + } + + NSWindow* parentWindow = (NSWindow*)parentWindowPtr; + NSWindow* modalWindow = (NSWindow*)modalWindowPtr; + + // Present the modal window as a sheet attached to the parent window + // Must be dispatched to the main thread for UI thread safety + dispatch_async(dispatch_get_main_queue(), ^{ + [parentWindow beginSheet:modalWindow completionHandler:^(NSModalResponse returnCode) { + // Sheet was dismissed - window will be released automatically + }]; + }); +} + // set the window position relative to the screen void windowSetRelativePosition(void* nsWindow, int x, int y) { WebviewWindow* window = (WebviewWindow*)nsWindow; @@ -1551,6 +1569,17 @@ func (w *macosWebviewWindow) setContentProtection(enabled bool) { C.setContentProtection(w.nsWindow, C.bool(enabled)) } +func (w *macosWebviewWindow) attachModal(modalWindow *WebviewWindow) { + if modalWindow == nil || modalWindow.impl == nil || modalWindow.isDestroyed() { + return + } + modalNativeWindow := modalWindow.impl.nativeWindow() + if modalNativeWindow == nil { + return + } + C.createModalWindow(w.nsWindow, modalNativeWindow) +} + func (w *macosWebviewWindow) cut() { } diff --git a/v3/pkg/application/webview_window_ios.go b/v3/pkg/application/webview_window_ios.go index bc3087f2c..481788c87 100644 --- a/v3/pkg/application/webview_window_ios.go +++ b/v3/pkg/application/webview_window_ios.go @@ -330,6 +330,10 @@ func (w *iosWebviewWindow) nativeWindow() unsafe.Pointer { return w.nativeHandle } +func (w *iosWebviewWindow) attachModal(modalWindow *WebviewWindow) { + // Modal windows are not supported on iOS +} + func (w *iosWebviewWindow) on(eventID uint) { // iOS event handling } diff --git a/v3/pkg/application/webview_window_linux.go b/v3/pkg/application/webview_window_linux.go index 9a5eb4131..4eddadbed 100644 --- a/v3/pkg/application/webview_window_linux.go +++ b/v3/pkg/application/webview_window_linux.go @@ -410,6 +410,10 @@ func (w *linuxWebviewWindow) nativeWindow() unsafe.Pointer { return unsafe.Pointer(w.window) } +func (w *linuxWebviewWindow) attachModal(modalWindow *WebviewWindow) { + // Modal windows are not supported on Linux +} + func (w *linuxWebviewWindow) print() error { w.execJS("window.print();") return nil diff --git a/v3/pkg/application/webview_window_windows.go b/v3/pkg/application/webview_window_windows.go index 16ba08f0b..434723fb4 100644 --- a/v3/pkg/application/webview_window_windows.go +++ b/v3/pkg/application/webview_window_windows.go @@ -78,6 +78,9 @@ type windowsWebviewWindow struct { // menubarTheme is the theme for the menubar menubarTheme *w32.MenuBarTheme + + // Modal window tracking + parentHWND w32.HWND // Parent window HWND when this window is a modal } func (w *windowsWebviewWindow) setMenu(menu *Menu) { @@ -233,6 +236,37 @@ func (w *windowsWebviewWindow) startDrag() error { return nil } +func (w *windowsWebviewWindow) attachModal(modalWindow *WebviewWindow) { + if modalWindow == nil || modalWindow.impl == nil || modalWindow.isDestroyed() { + return + } + + // Get the modal window's Windows implementation + modalWindowsImpl, ok := modalWindow.impl.(*windowsWebviewWindow) + if !ok { + return + } + + parentHWND := w.hwnd + modalHWND := modalWindowsImpl.hwnd + + // Set parent-child relationship using GWLP_HWNDPARENT + // This ensures the modal stays above parent and moves with it + w32.SetWindowLongPtr(modalHWND, w32.GWLP_HWNDPARENT, uintptr(parentHWND)) + + // Track the parent HWND in the modal window for cleanup + modalWindowsImpl.parentHWND = parentHWND + + // Disable the parent window to block interaction (Microsoft's recommended approach) + // This follows Windows modal dialog best practices + w32.EnableWindow(parentHWND, false) + + // Ensure modal window is shown and brought to front + w32.ShowWindow(modalHWND, w32.SW_SHOW) + w32.SetForegroundWindow(modalHWND) + w32.BringWindowToTop(modalHWND) +} + func (w *windowsWebviewWindow) nativeWindow() unsafe.Pointer { return unsafe.Pointer(w.hwnd) } @@ -725,6 +759,12 @@ func (w *windowsWebviewWindow) setRelativePosition(x int, y int) { } func (w *windowsWebviewWindow) destroy() { + // Re-enable parent window if this was a modal window + if w.parentHWND != 0 { + w32.EnableWindow(w.parentHWND, true) + w.parentHWND = 0 + } + w.parent.markAsDestroyed() // destroy the window w32.DestroyWindow(w.hwnd) @@ -1420,6 +1460,12 @@ func (w *windowsWebviewWindow) WndProc(msg uint32, wparam, lparam uintptr) uintp } defer func() { + // Re-enable parent window if this was a modal window + if w.parentHWND != 0 { + w32.EnableWindow(w.parentHWND, true) + w.parentHWND = 0 + } + windowsApp := globalApplication.impl.(*windowsApp) windowsApp.unregisterWindow(w) diff --git a/v3/pkg/application/window.go b/v3/pkg/application/window.go index 3f4949b16..ec810b115 100644 --- a/v3/pkg/application/window.go +++ b/v3/pkg/application/window.go @@ -91,6 +91,7 @@ type Window interface { Flash(enabled bool) Print() error RegisterHook(eventType events.WindowEventType, callback func(event *WindowEvent)) func() + AttachModal(modalWindow Window) shouldUnconditionallyClose() bool // Editing methods