From 838b159323dd545093c7d4a917846dfc725f3f57 Mon Sep 17 00:00:00 2001
From: WCY-dt <834421194@qq.com>
Date: Wed, 14 Jan 2026 17:32:47 +0800
Subject: [PATCH 1/7] feat(v3): Add WebviewPanel for embedding multiple
webviews in a window
This implements the feature requested in #1997 - allowing multiple webview
containers (panels) within a single window.
## Features
- WebviewPanel: A new type for embedded webview panels within a window
- Cross-platform support: Windows (WebView2), macOS (WKWebView), Linux (WebKit2GTK)
- Absolute positioning with X, Y, Width, Height coordinates
- Z-index support for panel stacking order
- URL and HTML content loading
- JavaScript execution in panel context
- Show/Hide/Focus/Zoom controls
- Developer tools support
- Layout helper methods: DockLeft, DockRight, DockTop, DockBottom, FillWindow, FillBeside
## API
Go:
```go
panel := window.NewPanel(application.WebviewPanelOptions{
Name: "sidebar",
X: 0, Y: 50,
Width: 200, Height: 600,
URL: "https://example.com",
})
panel.SetBounds(rect).SetURL(url).Show()
```
TypeScript:
```typescript
const panel = Panel.Get("sidebar");
await panel.SetURL("https://wails.io");
```
## Files Added
- webview_panel.go - Core panel struct and methods
- webview_panel_options.go - Panel configuration options
- webview_panel_windows.go - Windows implementation
- webview_panel_darwin.go - macOS implementation
- webview_panel_linux.go - Linux implementation
- webview_panel_android.go - Android stub
- webview_panel_ios.go - iOS stub
- messageprocessor_panel.go - Frontend-to-backend communication
- panel.ts - TypeScript runtime API
- examples/webview-panel/ - Demo application
Closes #1997
---
v3/examples/webview-panel/README.md | 143 ++++++
v3/examples/webview-panel/main.go | 203 ++++++++
.../desktop/@wailsio/runtime/src/index.ts | 2 +
.../desktop/@wailsio/runtime/src/panel.ts | 282 +++++++++++
.../desktop/@wailsio/runtime/src/runtime.ts | 2 +
v3/pkg/application/messageprocessor.go | 4 +
v3/pkg/application/messageprocessor_panel.go | 187 +++++++
v3/pkg/application/webview_panel.go | 465 ++++++++++++++++++
v3/pkg/application/webview_panel_android.go | 33 ++
v3/pkg/application/webview_panel_darwin.go | 448 +++++++++++++++++
v3/pkg/application/webview_panel_ios.go | 33 ++
v3/pkg/application/webview_panel_linux.go | 380 ++++++++++++++
v3/pkg/application/webview_panel_options.go | 102 ++++
v3/pkg/application/webview_panel_windows.go | 395 +++++++++++++++
v3/pkg/application/webview_window.go | 130 +++++
15 files changed, 2809 insertions(+)
create mode 100644 v3/examples/webview-panel/README.md
create mode 100644 v3/examples/webview-panel/main.go
create mode 100644 v3/internal/runtime/desktop/@wailsio/runtime/src/panel.ts
create mode 100644 v3/pkg/application/messageprocessor_panel.go
create mode 100644 v3/pkg/application/webview_panel.go
create mode 100644 v3/pkg/application/webview_panel_android.go
create mode 100644 v3/pkg/application/webview_panel_darwin.go
create mode 100644 v3/pkg/application/webview_panel_ios.go
create mode 100644 v3/pkg/application/webview_panel_linux.go
create mode 100644 v3/pkg/application/webview_panel_options.go
create mode 100644 v3/pkg/application/webview_panel_windows.go
diff --git a/v3/examples/webview-panel/README.md b/v3/examples/webview-panel/README.md
new file mode 100644
index 000000000..778ef3d61
--- /dev/null
+++ b/v3/examples/webview-panel/README.md
@@ -0,0 +1,143 @@
+# WebView Panel Example
+
+This example demonstrates the **WebviewPanel** feature - embedding multiple independent webview panels within a single window. This is similar to Electron's BrowserView/WebContentsView and addresses [GitHub issue #1997](https://github.com/wailsapp/wails/issues/1997).
+
+## Features
+
+- Create multiple webview panels within a single window
+- Panels are absolutely positioned with X, Y, Width, Height
+- Each panel can load different URLs or HTML content
+- Independent JavaScript execution in each panel
+- Z-index support for panel stacking
+- Layout helper methods for common patterns (DockLeft, DockRight, etc.)
+
+## Running
+
+```bash
+cd v3/examples/webview-panel
+go run main.go
+```
+
+## Use Cases
+
+WebviewPanel is ideal for:
+
+- **IDE-like layouts**: Editor + preview + terminal panels
+- **Browser-style apps**: Tab bar + content area
+- **Dashboard apps**: Navigation sidebar + main content
+- **Email clients**: Folder list + message list + preview pane
+- **News readers**: Article list + external website viewer
+- **Dev tools**: App preview + inspector panels
+
+## API Overview
+
+### Creating Panels
+
+```go
+// Create a panel with explicit positioning
+panel := window.NewPanel(application.WebviewPanelOptions{
+ Name: "sidebar",
+ X: 0,
+ Y: 50,
+ Width: 200,
+ Height: 600,
+ URL: "https://example.com",
+ // Or use HTML:
+ // HTML: "
Hello Panel!
",
+})
+```
+
+### Layout Helpers
+
+```go
+// Dock to edges
+sidebar := window.NewPanel(opts).DockLeft(200) // Left sidebar
+inspector := window.NewPanel(opts).DockRight(300) // Right panel
+toolbar := window.NewPanel(opts).DockTop(50) // Top toolbar
+statusBar := window.NewPanel(opts).DockBottom(30) // Bottom status
+
+// Fill remaining space
+content := window.NewPanel(opts).FillBeside(sidebar, "right")
+
+// Fill entire window
+fullPanel := window.NewPanel(opts).FillWindow()
+```
+
+### Panel Manipulation
+
+```go
+// Position and size
+panel.SetBounds(application.Rect{X: 100, Y: 50, Width: 400, Height: 300})
+panel.SetPosition(200, 100)
+panel.SetSize(500, 400)
+
+// Content
+panel.SetURL("https://wails.io")
+panel.SetHTML("Dynamic content
")
+panel.ExecJS("console.log('Hello from panel!')")
+panel.Reload()
+
+// Visibility
+panel.Show()
+panel.Hide()
+visible := panel.IsVisible()
+
+// Stacking order
+panel.SetZIndex(10)
+
+// Focus
+panel.Focus()
+
+// Zoom
+panel.SetZoom(1.5)
+
+// Developer tools
+panel.OpenDevTools()
+
+// Cleanup
+panel.Destroy()
+```
+
+### Getting Panels
+
+```go
+// Get panel by name
+panel := window.GetPanel("sidebar")
+
+// Get panel by ID
+panel := window.GetPanelByID(1)
+
+// Get all panels
+panels := window.GetPanels()
+
+// Remove panel
+window.RemovePanel("sidebar")
+```
+
+## TypeScript/Frontend API
+
+```typescript
+import { Panel } from '@wailsio/runtime';
+
+// Get a reference to a panel
+const panel = Panel.Get("content");
+
+// Manipulate from frontend
+await panel.SetBounds({ x: 100, y: 50, width: 500, height: 400 });
+await panel.SetURL("https://wails.io");
+await panel.ExecJS("document.body.style.background = 'red'");
+await panel.Show();
+await panel.Hide();
+await panel.Focus();
+```
+
+## Key Differences from Windows
+
+| Feature | WebviewWindow | WebviewPanel |
+|---------|---------------|--------------|
+| Has title bar | ✅ | ❌ |
+| Can be minimized/maximized | ✅ | ❌ |
+| Independent window | ✅ | ❌ (child of window) |
+| Can show external URLs | ✅ | ✅ |
+| Multiple per app | ✅ | ✅ (multiple per window) |
+| Position relative to | Screen | Parent window |
diff --git a/v3/examples/webview-panel/main.go b/v3/examples/webview-panel/main.go
new file mode 100644
index 000000000..c52ad18b8
--- /dev/null
+++ b/v3/examples/webview-panel/main.go
@@ -0,0 +1,203 @@
+package main
+
+import (
+ "log"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+func main() {
+ app := application.New(application.Options{
+ Name: "WebviewPanel Demo",
+ Description: "Demonstrates embedding multiple webview panels within a single window",
+ Mac: application.MacOptions{
+ ApplicationShouldTerminateAfterLastWindowClosed: true,
+ },
+ })
+
+ // Create the main window with a simple header
+ window := app.Window.NewWithOptions(application.WebviewWindowOptions{
+ Title: "WebviewPanel Demo - Multi-Panel Layout",
+ Width: 1200,
+ Height: 700,
+ BackgroundType: application.BackgroundTypeSolid,
+ BackgroundColour: application.NewRGB(45, 45, 45),
+ HTML: `
+
+
+ WebviewPanel Demo
+
+
+
+
+
+`,
+ })
+
+ // =====================================================================
+ // Example 1: Using explicit coordinates (traditional approach)
+ // =====================================================================
+
+ // Create a sidebar panel on the left with explicit positioning
+ sidebarPanel := window.NewPanel(application.WebviewPanelOptions{
+ Name: "sidebar",
+ X: 0,
+ Y: 50, // Start below the 50px header
+ Width: 220,
+ Height: 650,
+ HTML: `
+
+
+
+
+
+
+
Navigation
+
+ - 🏠 Dashboard
+ - 📊 Analytics
+ - 📁 Projects
+ - 👥 Team
+ - ⚙️ Settings
+
+
+
+
Favorites
+
+ - ⭐ Starred
+ - 🕐 Recent
+ - 📌 Pinned
+
+
+
+`,
+ BackgroundColour: application.NewRGB(30, 30, 46),
+ Visible: boolPtr(true),
+ ZIndex: 1,
+ })
+
+ // =====================================================================
+ // Example 2: Content panel showing an external website
+ // This demonstrates loading external URLs in an embedded webview
+ // =====================================================================
+
+ contentPanel := window.NewPanel(application.WebviewPanelOptions{
+ Name: "content",
+ X: 220, // Right of sidebar
+ Y: 50, // Below header
+ Width: 980,
+ Height: 650,
+ URL: "https://wails.io", // External website
+ DevToolsEnabled: boolPtr(true),
+ Visible: boolPtr(true),
+ BackgroundColour: application.NewRGB(255, 255, 255),
+ ZIndex: 1,
+ })
+
+ // Log panel creation
+ log.Printf("✅ Created sidebar panel: %s (ID: %d)", sidebarPanel.Name(), sidebarPanel.ID())
+ log.Printf("✅ Created content panel: %s (ID: %d)", contentPanel.Name(), contentPanel.ID())
+
+ // =====================================================================
+ // Alternative: Using layout helper methods (commented examples)
+ // =====================================================================
+ //
+ // // Create a panel and dock it to the left
+ // sidebar := window.NewPanel(opts).DockLeft(200)
+ //
+ // // Create a panel and fill space beside another
+ // content := window.NewPanel(opts).FillBeside(sidebar, "right")
+ //
+ // // Create a panel that fills the entire window
+ // fullscreen := window.NewPanel(opts).FillWindow()
+ //
+ // // Dock panels to different edges
+ // toolbar := window.NewPanel(opts).DockTop(50)
+ // statusBar := window.NewPanel(opts).DockBottom(30)
+ //
+
+ // Run the application
+ err := app.Run()
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+
+func boolPtr(b bool) *bool {
+ return &b
+}
diff --git a/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts b/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts
index 1ea862e31..e31aeff1c 100644
--- a/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts
+++ b/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts
@@ -26,6 +26,7 @@ import * as Flags from "./flags.js";
import * as Screens from "./screens.js";
import * as System from "./system.js";
import * as IOS from "./ios.js";
+import { Panel } from "./panel.js";
import Window, { handleDragEnter, handleDragLeave, handleDragOver } from "./window.js";
import * as WML from "./wml.js";
@@ -37,6 +38,7 @@ export {
Dialogs,
Events,
Flags,
+ Panel,
Screens,
System,
IOS,
diff --git a/v3/internal/runtime/desktop/@wailsio/runtime/src/panel.ts b/v3/internal/runtime/desktop/@wailsio/runtime/src/panel.ts
new file mode 100644
index 000000000..d9f4ad71b
--- /dev/null
+++ b/v3/internal/runtime/desktop/@wailsio/runtime/src/panel.ts
@@ -0,0 +1,282 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+
+import { newRuntimeCaller, objectNames } from "./runtime.js";
+
+// Panel method constants - must match messageprocessor_panel.go
+const SetBoundsMethod = 0;
+const GetBoundsMethod = 1;
+const SetZIndexMethod = 2;
+const SetURLMethod = 3;
+const SetHTMLMethod = 4;
+const ExecJSMethod = 5;
+const ReloadMethod = 6;
+const ForceReloadMethod = 7;
+const ShowMethod = 8;
+const HideMethod = 9;
+const IsVisibleMethod = 10;
+const SetZoomMethod = 11;
+const GetZoomMethod = 12;
+const FocusMethod = 13;
+const IsFocusedMethod = 14;
+const OpenDevToolsMethod = 15;
+const DestroyMethod = 16;
+const NameMethod = 17;
+
+/**
+ * A record describing the bounds (position and size) of a panel.
+ */
+export interface Bounds {
+ /** The X position of the panel within the window. */
+ x: number;
+ /** The Y position of the panel within the window. */
+ y: number;
+ /** The width of the panel. */
+ width: number;
+ /** The height of the panel. */
+ height: number;
+}
+
+// Private field names
+const callerSym = Symbol("caller");
+const panelNameSym = Symbol("panelName");
+
+/**
+ * Panel represents an embedded webview panel within a window.
+ * Panels allow embedding multiple webview instances in a single window,
+ * similar to Electron's BrowserView/WebContentsView.
+ */
+export class Panel {
+ // Private fields
+ private [callerSym]: (method: number, args?: any) => Promise;
+ private [panelNameSym]: string;
+
+ /**
+ * Creates a new Panel instance.
+ *
+ * @param panelName - The name of the panel to control.
+ * @param windowName - The name of the parent window (optional, defaults to current window).
+ */
+ constructor(panelName: string, windowName: string = '') {
+ this[panelNameSym] = panelName;
+ this[callerSym] = newRuntimeCaller(objectNames.Panel, windowName);
+
+ // Bind instance methods for use in event handlers
+ for (const method of Object.getOwnPropertyNames(Panel.prototype)) {
+ if (method !== "constructor" && typeof (this as any)[method] === "function") {
+ (this as any)[method] = (this as any)[method].bind(this);
+ }
+ }
+ }
+
+ /**
+ * Gets a reference to the specified panel.
+ *
+ * @param panelName - The name of the panel to get.
+ * @param windowName - The name of the parent window (optional).
+ * @returns A new Panel instance.
+ */
+ static Get(panelName: string, windowName: string = ''): Panel {
+ return new Panel(panelName, windowName);
+ }
+
+ /**
+ * Sets the position and size of the panel.
+ *
+ * @param bounds - The new bounds for the panel.
+ */
+ SetBounds(bounds: Bounds): Promise {
+ return this[callerSym](SetBoundsMethod, {
+ panel: this[panelNameSym],
+ x: bounds.x,
+ y: bounds.y,
+ width: bounds.width,
+ height: bounds.height
+ });
+ }
+
+ /**
+ * Gets the current position and size of the panel.
+ *
+ * @returns The current bounds of the panel.
+ */
+ GetBounds(): Promise {
+ return this[callerSym](GetBoundsMethod, {
+ panel: this[panelNameSym]
+ });
+ }
+
+ /**
+ * Sets the z-index (stacking order) of the panel.
+ *
+ * @param zIndex - The z-index value (higher values appear on top).
+ */
+ SetZIndex(zIndex: number): Promise {
+ return this[callerSym](SetZIndexMethod, {
+ panel: this[panelNameSym],
+ zIndex
+ });
+ }
+
+ /**
+ * Navigates the panel to the specified URL.
+ *
+ * @param url - The URL to navigate to.
+ */
+ SetURL(url: string): Promise {
+ return this[callerSym](SetURLMethod, {
+ panel: this[panelNameSym],
+ url
+ });
+ }
+
+ /**
+ * Sets the HTML content of the panel directly.
+ *
+ * @param html - The HTML content to load.
+ */
+ SetHTML(html: string): Promise {
+ return this[callerSym](SetHTMLMethod, {
+ panel: this[panelNameSym],
+ html
+ });
+ }
+
+ /**
+ * Executes JavaScript code in the panel's context.
+ *
+ * @param js - The JavaScript code to execute.
+ */
+ ExecJS(js: string): Promise {
+ return this[callerSym](ExecJSMethod, {
+ panel: this[panelNameSym],
+ js
+ });
+ }
+
+ /**
+ * Reloads the current page in the panel.
+ */
+ Reload(): Promise {
+ return this[callerSym](ReloadMethod, {
+ panel: this[panelNameSym]
+ });
+ }
+
+ /**
+ * Forces a reload of the page, ignoring cached content.
+ */
+ ForceReload(): Promise {
+ return this[callerSym](ForceReloadMethod, {
+ panel: this[panelNameSym]
+ });
+ }
+
+ /**
+ * Shows the panel (makes it visible).
+ */
+ Show(): Promise {
+ return this[callerSym](ShowMethod, {
+ panel: this[panelNameSym]
+ });
+ }
+
+ /**
+ * Hides the panel (makes it invisible).
+ */
+ Hide(): Promise {
+ return this[callerSym](HideMethod, {
+ panel: this[panelNameSym]
+ });
+ }
+
+ /**
+ * Checks if the panel is currently visible.
+ *
+ * @returns True if the panel is visible, false otherwise.
+ */
+ IsVisible(): Promise {
+ return this[callerSym](IsVisibleMethod, {
+ panel: this[panelNameSym]
+ });
+ }
+
+ /**
+ * Sets the zoom level of the panel.
+ *
+ * @param zoom - The zoom level (1.0 = 100%).
+ */
+ SetZoom(zoom: number): Promise {
+ return this[callerSym](SetZoomMethod, {
+ panel: this[panelNameSym],
+ zoom
+ });
+ }
+
+ /**
+ * Gets the current zoom level of the panel.
+ *
+ * @returns The current zoom level.
+ */
+ GetZoom(): Promise {
+ return this[callerSym](GetZoomMethod, {
+ panel: this[panelNameSym]
+ });
+ }
+
+ /**
+ * Focuses the panel (gives it keyboard input focus).
+ */
+ Focus(): Promise {
+ return this[callerSym](FocusMethod, {
+ panel: this[panelNameSym]
+ });
+ }
+
+ /**
+ * Checks if the panel currently has keyboard focus.
+ *
+ * @returns True if the panel is focused, false otherwise.
+ */
+ IsFocused(): Promise {
+ return this[callerSym](IsFocusedMethod, {
+ panel: this[panelNameSym]
+ });
+ }
+
+ /**
+ * Opens the developer tools for the panel.
+ */
+ OpenDevTools(): Promise {
+ return this[callerSym](OpenDevToolsMethod, {
+ panel: this[panelNameSym]
+ });
+ }
+
+ /**
+ * Destroys the panel, removing it from the window.
+ */
+ Destroy(): Promise {
+ return this[callerSym](DestroyMethod, {
+ panel: this[panelNameSym]
+ });
+ }
+
+ /**
+ * Gets the name of the panel.
+ *
+ * @returns The name of the panel.
+ */
+ Name(): Promise {
+ return this[callerSym](NameMethod, {
+ panel: this[panelNameSym]
+ });
+ }
+}
diff --git a/v3/internal/runtime/desktop/@wailsio/runtime/src/runtime.ts b/v3/internal/runtime/desktop/@wailsio/runtime/src/runtime.ts
index eaa70d838..4fdcbf1ac 100644
--- a/v3/internal/runtime/desktop/@wailsio/runtime/src/runtime.ts
+++ b/v3/internal/runtime/desktop/@wailsio/runtime/src/runtime.ts
@@ -29,6 +29,8 @@ export const objectNames = Object.freeze({
Browser: 9,
CancelCall: 10,
IOS: 11,
+ Android: 12,
+ Panel: 13,
});
export let clientId = nanoid();
diff --git a/v3/pkg/application/messageprocessor.go b/v3/pkg/application/messageprocessor.go
index 4829d3dbf..78948a5ee 100644
--- a/v3/pkg/application/messageprocessor.go
+++ b/v3/pkg/application/messageprocessor.go
@@ -27,6 +27,7 @@ const (
cancelCallRequest = 10
iosRequest = 11
androidRequest = 12
+ panelRequest = 13
)
var objectNames = map[int]string{
@@ -43,6 +44,7 @@ var objectNames = map[int]string{
cancelCallRequest: "CancelCall",
iosRequest: "iOS",
androidRequest: "Android",
+ panelRequest: "Panel",
}
type RuntimeRequest struct {
@@ -127,6 +129,8 @@ func (m *MessageProcessor) HandleRuntimeCallWithIDs(ctx context.Context, req *Ru
return m.processIOSMethod(req, targetWindow)
case androidRequest:
return m.processAndroidMethod(req, targetWindow)
+ case panelRequest:
+ return m.processPanelMethod(req, targetWindow)
default:
return nil, errs.NewInvalidRuntimeCallErrorf("unknown object %d", req.Object)
}
diff --git a/v3/pkg/application/messageprocessor_panel.go b/v3/pkg/application/messageprocessor_panel.go
new file mode 100644
index 000000000..1198d2040
--- /dev/null
+++ b/v3/pkg/application/messageprocessor_panel.go
@@ -0,0 +1,187 @@
+package application
+
+import (
+ "fmt"
+
+ "github.com/wailsapp/wails/v3/pkg/errs"
+)
+
+// Panel method constants for frontend-to-backend communication
+const (
+ PanelSetBounds = 0
+ PanelGetBounds = 1
+ PanelSetZIndex = 2
+ PanelSetURL = 3
+ PanelSetHTML = 4
+ PanelExecJS = 5
+ PanelReload = 6
+ PanelForceReload = 7
+ PanelShow = 8
+ PanelHide = 9
+ PanelIsVisible = 10
+ PanelSetZoom = 11
+ PanelGetZoom = 12
+ PanelFocus = 13
+ PanelIsFocused = 14
+ PanelOpenDevTools = 15
+ PanelDestroy = 16
+ PanelName = 17
+)
+
+var panelMethodNames = map[int]string{
+ PanelSetBounds: "SetBounds",
+ PanelGetBounds: "GetBounds",
+ PanelSetZIndex: "SetZIndex",
+ PanelSetURL: "SetURL",
+ PanelSetHTML: "SetHTML",
+ PanelExecJS: "ExecJS",
+ PanelReload: "Reload",
+ PanelForceReload: "ForceReload",
+ PanelShow: "Show",
+ PanelHide: "Hide",
+ PanelIsVisible: "IsVisible",
+ PanelSetZoom: "SetZoom",
+ PanelGetZoom: "GetZoom",
+ PanelFocus: "Focus",
+ PanelIsFocused: "IsFocused",
+ PanelOpenDevTools: "OpenDevTools",
+ PanelDestroy: "Destroy",
+ PanelName: "Name",
+}
+
+func (m *MessageProcessor) processPanelMethod(
+ req *RuntimeRequest,
+ window Window,
+) (any, error) {
+ args := req.Args.AsMap()
+
+ // Get the WebviewWindow to access panels
+ ww, ok := window.(*WebviewWindow)
+ if !ok {
+ return nil, errs.NewInvalidRuntimeCallErrorf("window is not a WebviewWindow")
+ }
+
+ // Get panel name from args
+ panelName := args.String("panel")
+ if panelName == nil || *panelName == "" {
+ return nil, errs.NewInvalidRuntimeCallErrorf("panel name is required")
+ }
+
+ // Get the panel
+ panel := ww.GetPanel(*panelName)
+ if panel == nil {
+ // Try by ID
+ panelID := args.UInt("panelId")
+ if panelID != nil && *panelID > 0 {
+ panel = ww.GetPanelByID(uint(*panelID))
+ }
+ }
+ if panel == nil {
+ return nil, errs.NewInvalidRuntimeCallErrorf("panel not found: %s", *panelName)
+ }
+
+ switch req.Method {
+ case PanelSetBounds:
+ x := args.Int("x")
+ y := args.Int("y")
+ width := args.Int("width")
+ height := args.Int("height")
+ if x == nil || y == nil || width == nil || height == nil {
+ return nil, errs.NewInvalidRuntimeCallErrorf("x, y, width, and height are required")
+ }
+ panel.SetBounds(Rect{X: *x, Y: *y, Width: *width, Height: *height})
+ return unit, nil
+
+ case PanelGetBounds:
+ bounds := panel.Bounds()
+ return map[string]interface{}{
+ "x": bounds.X,
+ "y": bounds.Y,
+ "width": bounds.Width,
+ "height": bounds.Height,
+ }, nil
+
+ case PanelSetZIndex:
+ zIndex := args.Int("zIndex")
+ if zIndex == nil {
+ return nil, errs.NewInvalidRuntimeCallErrorf("zIndex is required")
+ }
+ panel.SetZIndex(*zIndex)
+ return unit, nil
+
+ case PanelSetURL:
+ url := args.String("url")
+ if url == nil {
+ return nil, errs.NewInvalidRuntimeCallErrorf("url is required")
+ }
+ panel.SetURL(*url)
+ return unit, nil
+
+ case PanelSetHTML:
+ html := args.String("html")
+ if html == nil {
+ return nil, errs.NewInvalidRuntimeCallErrorf("html is required")
+ }
+ panel.SetHTML(*html)
+ return unit, nil
+
+ case PanelExecJS:
+ js := args.String("js")
+ if js == nil {
+ return nil, errs.NewInvalidRuntimeCallErrorf("js is required")
+ }
+ panel.ExecJS(*js)
+ return unit, nil
+
+ case PanelReload:
+ panel.Reload()
+ return unit, nil
+
+ case PanelForceReload:
+ panel.ForceReload()
+ return unit, nil
+
+ case PanelShow:
+ panel.Show()
+ return unit, nil
+
+ case PanelHide:
+ panel.Hide()
+ return unit, nil
+
+ case PanelIsVisible:
+ return panel.IsVisible(), nil
+
+ case PanelSetZoom:
+ zoom := args.Float64("zoom")
+ if zoom == nil {
+ return nil, errs.NewInvalidRuntimeCallErrorf("zoom is required")
+ }
+ panel.SetZoom(*zoom)
+ return unit, nil
+
+ case PanelGetZoom:
+ return panel.GetZoom(), nil
+
+ case PanelFocus:
+ panel.Focus()
+ return unit, nil
+
+ case PanelIsFocused:
+ return panel.IsFocused(), nil
+
+ case PanelOpenDevTools:
+ panel.OpenDevTools()
+ return unit, nil
+
+ case PanelDestroy:
+ panel.Destroy()
+ return unit, nil
+
+ case PanelName:
+ return panel.Name(), nil
+
+ default:
+ return nil, fmt.Errorf("unknown panel method: %d", req.Method)
+ }
+}
diff --git a/v3/pkg/application/webview_panel.go b/v3/pkg/application/webview_panel.go
new file mode 100644
index 000000000..e9d6a5c86
--- /dev/null
+++ b/v3/pkg/application/webview_panel.go
@@ -0,0 +1,465 @@
+package application
+
+import (
+ "fmt"
+ "sync"
+ "sync/atomic"
+)
+
+// webviewPanelImpl is the platform-specific interface for WebviewPanel
+type webviewPanelImpl interface {
+ // Lifecycle
+ create()
+ destroy()
+
+ // Position and size
+ setBounds(bounds Rect)
+ bounds() Rect
+ setZIndex(zIndex int)
+
+ // Content
+ setURL(url string)
+ setHTML(html string)
+ execJS(js string)
+ reload()
+ forceReload()
+
+ // Visibility
+ show()
+ hide()
+ isVisible() bool
+
+ // Zoom
+ setZoom(zoom float64)
+ getZoom() float64
+
+ // DevTools
+ openDevTools()
+
+ // Focus
+ focus()
+ isFocused() bool
+}
+
+var panelID uint32
+var panelIDLock sync.Mutex
+
+func getNextPanelID() uint {
+ panelIDLock.Lock()
+ defer panelIDLock.Unlock()
+ return uint(atomic.AddUint32(&panelID, 1))
+}
+
+// WebviewPanel represents an embedded webview within a window.
+// Unlike WebviewWindow, a WebviewPanel is a child view that exists within
+// a parent window and can be positioned anywhere within that window.
+// This is similar to Electron's BrowserView or the deprecated webview tag.
+type WebviewPanel struct {
+ id uint
+ name string
+ options WebviewPanelOptions
+ impl webviewPanelImpl
+ parent *WebviewWindow
+
+ // Track if the panel has been destroyed
+ destroyed bool
+ destroyedLock sync.RWMutex
+
+ // Track if runtime has been loaded
+ runtimeLoaded bool
+ pendingJS []string
+}
+
+// NewPanel creates a new WebviewPanel with the given options.
+// The panel must be associated with a parent window via window.AddPanel().
+func NewPanel(options WebviewPanelOptions) *WebviewPanel {
+ id := getNextPanelID()
+
+ // Apply defaults
+ if options.Width == 0 {
+ options.Width = 400
+ }
+ if options.Height == 0 {
+ options.Height = 300
+ }
+ if options.ZIndex == 0 {
+ options.ZIndex = 1
+ }
+ if options.Zoom == 0 {
+ options.Zoom = 1.0
+ }
+ if options.Name == "" {
+ options.Name = fmt.Sprintf("panel-%d", id)
+ }
+ // Default to visible
+ if options.Visible == nil {
+ visible := true
+ options.Visible = &visible
+ }
+
+ return &WebviewPanel{
+ id: id,
+ name: options.Name,
+ options: options,
+ }
+}
+
+// ID returns the unique identifier for this panel
+func (p *WebviewPanel) ID() uint {
+ return p.id
+}
+
+// Name returns the name of this panel
+func (p *WebviewPanel) Name() string {
+ return p.name
+}
+
+// Parent returns the parent window of this panel
+func (p *WebviewPanel) Parent() *WebviewWindow {
+ return p.parent
+}
+
+// SetBounds sets the position and size of the panel within its parent window
+func (p *WebviewPanel) SetBounds(bounds Rect) *WebviewPanel {
+ p.options.X = bounds.X
+ p.options.Y = bounds.Y
+ p.options.Width = bounds.Width
+ p.options.Height = bounds.Height
+
+ if p.impl != nil && !p.isDestroyed() {
+ InvokeSync(func() {
+ p.impl.setBounds(bounds)
+ })
+ }
+ return p
+}
+
+// Bounds returns the current bounds of the panel
+func (p *WebviewPanel) Bounds() Rect {
+ if p.impl != nil && !p.isDestroyed() {
+ return InvokeSyncWithResult(p.impl.bounds)
+ }
+ return Rect{
+ X: p.options.X,
+ Y: p.options.Y,
+ Width: p.options.Width,
+ Height: p.options.Height,
+ }
+}
+
+// SetPosition sets the position of the panel within its parent window
+func (p *WebviewPanel) SetPosition(x, y int) *WebviewPanel {
+ bounds := p.Bounds()
+ bounds.X = x
+ bounds.Y = y
+ return p.SetBounds(bounds)
+}
+
+// Position returns the current position of the panel
+func (p *WebviewPanel) Position() (int, int) {
+ bounds := p.Bounds()
+ return bounds.X, bounds.Y
+}
+
+// SetSize sets the size of the panel
+func (p *WebviewPanel) SetSize(width, height int) *WebviewPanel {
+ bounds := p.Bounds()
+ bounds.Width = width
+ bounds.Height = height
+ return p.SetBounds(bounds)
+}
+
+// Size returns the current size of the panel
+func (p *WebviewPanel) Size() (int, int) {
+ bounds := p.Bounds()
+ return bounds.Width, bounds.Height
+}
+
+// SetZIndex sets the stacking order of the panel
+func (p *WebviewPanel) SetZIndex(zIndex int) *WebviewPanel {
+ p.options.ZIndex = zIndex
+ if p.impl != nil && !p.isDestroyed() {
+ InvokeSync(func() {
+ p.impl.setZIndex(zIndex)
+ })
+ }
+ return p
+}
+
+// ZIndex returns the current z-index of the panel
+func (p *WebviewPanel) ZIndex() int {
+ return p.options.ZIndex
+}
+
+// SetURL navigates the panel to the specified URL
+func (p *WebviewPanel) SetURL(url string) *WebviewPanel {
+ p.options.URL = url
+ if p.impl != nil && !p.isDestroyed() {
+ InvokeSync(func() {
+ p.impl.setURL(url)
+ })
+ }
+ return p
+}
+
+// URL returns the current URL of the panel
+func (p *WebviewPanel) URL() string {
+ return p.options.URL
+}
+
+// SetHTML sets the HTML content of the panel
+func (p *WebviewPanel) SetHTML(html string) *WebviewPanel {
+ p.options.HTML = html
+ if p.impl != nil && !p.isDestroyed() {
+ InvokeSync(func() {
+ p.impl.setHTML(html)
+ })
+ }
+ return p
+}
+
+// ExecJS executes JavaScript in the panel's context
+func (p *WebviewPanel) ExecJS(js string) {
+ if p.impl == nil || p.isDestroyed() {
+ return
+ }
+ if p.runtimeLoaded {
+ InvokeSync(func() {
+ p.impl.execJS(js)
+ })
+ } else {
+ p.pendingJS = append(p.pendingJS, js)
+ }
+}
+
+// Reload reloads the current page
+func (p *WebviewPanel) Reload() {
+ if p.impl != nil && !p.isDestroyed() {
+ InvokeSync(p.impl.reload)
+ }
+}
+
+// ForceReload reloads the current page, bypassing the cache
+func (p *WebviewPanel) ForceReload() {
+ if p.impl != nil && !p.isDestroyed() {
+ InvokeSync(p.impl.forceReload)
+ }
+}
+
+// Show makes the panel visible
+func (p *WebviewPanel) Show() *WebviewPanel {
+ visible := true
+ p.options.Visible = &visible
+ if p.impl != nil && !p.isDestroyed() {
+ InvokeSync(p.impl.show)
+ }
+ return p
+}
+
+// Hide hides the panel
+func (p *WebviewPanel) Hide() *WebviewPanel {
+ visible := false
+ p.options.Visible = &visible
+ if p.impl != nil && !p.isDestroyed() {
+ InvokeSync(p.impl.hide)
+ }
+ return p
+}
+
+// IsVisible returns whether the panel is currently visible
+func (p *WebviewPanel) IsVisible() bool {
+ if p.impl != nil && !p.isDestroyed() {
+ return InvokeSyncWithResult(p.impl.isVisible)
+ }
+ return p.options.Visible != nil && *p.options.Visible
+}
+
+// SetZoom sets the zoom level of the panel
+func (p *WebviewPanel) SetZoom(zoom float64) *WebviewPanel {
+ p.options.Zoom = zoom
+ if p.impl != nil && !p.isDestroyed() {
+ InvokeSync(func() {
+ p.impl.setZoom(zoom)
+ })
+ }
+ return p
+}
+
+// GetZoom returns the current zoom level of the panel
+func (p *WebviewPanel) GetZoom() float64 {
+ if p.impl != nil && !p.isDestroyed() {
+ return InvokeSyncWithResult(p.impl.getZoom)
+ }
+ return p.options.Zoom
+}
+
+// OpenDevTools opens the developer tools for this panel
+func (p *WebviewPanel) OpenDevTools() {
+ if p.impl != nil && !p.isDestroyed() {
+ InvokeSync(p.impl.openDevTools)
+ }
+}
+
+// Focus gives focus to this panel
+func (p *WebviewPanel) Focus() {
+ if p.impl != nil && !p.isDestroyed() {
+ InvokeSync(p.impl.focus)
+ }
+}
+
+// IsFocused returns whether this panel currently has focus
+func (p *WebviewPanel) IsFocused() bool {
+ if p.impl != nil && !p.isDestroyed() {
+ return InvokeSyncWithResult(p.impl.isFocused)
+ }
+ return false
+}
+
+// Destroy removes the panel from its parent window and releases resources
+func (p *WebviewPanel) Destroy() {
+ if p.isDestroyed() {
+ return
+ }
+
+ p.destroyedLock.Lock()
+ p.destroyed = true
+ p.destroyedLock.Unlock()
+
+ if p.impl != nil {
+ InvokeSync(p.impl.destroy)
+ }
+
+ // Remove from parent
+ if p.parent != nil {
+ p.parent.removePanel(p.id)
+ }
+}
+
+// isDestroyed returns whether the panel has been destroyed
+func (p *WebviewPanel) isDestroyed() bool {
+ p.destroyedLock.RLock()
+ defer p.destroyedLock.RUnlock()
+ return p.destroyed
+}
+
+// run initializes the platform-specific implementation
+// This is called by the parent window when the panel is added
+func (p *WebviewPanel) run() {
+ if p.impl != nil {
+ return
+ }
+ p.impl = newPanelImpl(p)
+ InvokeSync(p.impl.create)
+}
+
+// markRuntimeLoaded is called when the runtime JavaScript has been loaded
+func (p *WebviewPanel) markRuntimeLoaded() {
+ p.runtimeLoaded = true
+ // Execute any pending JavaScript
+ for _, js := range p.pendingJS {
+ p.ExecJS(js)
+ }
+ p.pendingJS = nil
+}
+
+// =========================================================================
+// Layout Helper Methods
+// =========================================================================
+
+// FillWindow makes the panel fill the entire parent window.
+// This is a convenience method equivalent to setting position to (0,0)
+// and size to the window's content size.
+func (p *WebviewPanel) FillWindow() *WebviewPanel {
+ if p.parent == nil {
+ return p
+ }
+ width, height := p.parent.Size()
+ return p.SetBounds(Rect{X: 0, Y: 0, Width: width, Height: height})
+}
+
+// DockLeft positions the panel on the left side of the window with the specified width.
+// Height fills the window. Useful for sidebars and navigation panels.
+func (p *WebviewPanel) DockLeft(width int) *WebviewPanel {
+ if p.parent == nil {
+ return p
+ }
+ _, height := p.parent.Size()
+ return p.SetBounds(Rect{X: 0, Y: 0, Width: width, Height: height})
+}
+
+// DockRight positions the panel on the right side of the window with the specified width.
+// Height fills the window. Useful for property panels and inspectors.
+func (p *WebviewPanel) DockRight(width int) *WebviewPanel {
+ if p.parent == nil {
+ return p
+ }
+ windowWidth, height := p.parent.Size()
+ return p.SetBounds(Rect{X: windowWidth - width, Y: 0, Width: width, Height: height})
+}
+
+// DockTop positions the panel at the top of the window with the specified height.
+// Width fills the window. Useful for toolbars and header areas.
+func (p *WebviewPanel) DockTop(height int) *WebviewPanel {
+ if p.parent == nil {
+ return p
+ }
+ width, _ := p.parent.Size()
+ return p.SetBounds(Rect{X: 0, Y: 0, Width: width, Height: height})
+}
+
+// DockBottom positions the panel at the bottom of the window with the specified height.
+// Width fills the window. Useful for status bars and terminal panels.
+func (p *WebviewPanel) DockBottom(height int) *WebviewPanel {
+ if p.parent == nil {
+ return p
+ }
+ width, windowHeight := p.parent.Size()
+ return p.SetBounds(Rect{X: 0, Y: windowHeight - height, Width: width, Height: height})
+}
+
+// FillBeside fills the remaining space beside another panel.
+// The direction specifies whether to fill to the right, left, above, or below the reference panel.
+func (p *WebviewPanel) FillBeside(refPanel *WebviewPanel, direction string) *WebviewPanel {
+ if p.parent == nil || refPanel == nil {
+ return p
+ }
+
+ windowWidth, windowHeight := p.parent.Size()
+ refBounds := refPanel.Bounds()
+
+ var bounds Rect
+ switch direction {
+ case "right":
+ bounds = Rect{
+ X: refBounds.X + refBounds.Width,
+ Y: refBounds.Y,
+ Width: windowWidth - (refBounds.X + refBounds.Width),
+ Height: refBounds.Height,
+ }
+ case "left":
+ bounds = Rect{
+ X: 0,
+ Y: refBounds.Y,
+ Width: refBounds.X,
+ Height: refBounds.Height,
+ }
+ case "below":
+ bounds = Rect{
+ X: refBounds.X,
+ Y: refBounds.Y + refBounds.Height,
+ Width: refBounds.Width,
+ Height: windowHeight - (refBounds.Y + refBounds.Height),
+ }
+ case "above":
+ bounds = Rect{
+ X: refBounds.X,
+ Y: 0,
+ Width: refBounds.Width,
+ Height: refBounds.Y,
+ }
+ default:
+ return p
+ }
+
+ return p.SetBounds(bounds)
+}
diff --git a/v3/pkg/application/webview_panel_android.go b/v3/pkg/application/webview_panel_android.go
new file mode 100644
index 000000000..8e99aac7d
--- /dev/null
+++ b/v3/pkg/application/webview_panel_android.go
@@ -0,0 +1,33 @@
+//go:build android
+
+package application
+
+// Android stub implementation for WebviewPanel
+// Panels are not yet supported on Android
+
+type androidPanelImpl struct {
+ panel *WebviewPanel
+}
+
+func newPanelImpl(panel *WebviewPanel) webviewPanelImpl {
+ return &androidPanelImpl{panel: panel}
+}
+
+func (p *androidPanelImpl) create() {}
+func (p *androidPanelImpl) destroy() {}
+func (p *androidPanelImpl) setBounds(bounds Rect) {}
+func (p *androidPanelImpl) bounds() Rect { return Rect{} }
+func (p *androidPanelImpl) setZIndex(zIndex int) {}
+func (p *androidPanelImpl) setURL(url string) {}
+func (p *androidPanelImpl) setHTML(html string) {}
+func (p *androidPanelImpl) execJS(js string) {}
+func (p *androidPanelImpl) reload() {}
+func (p *androidPanelImpl) forceReload() {}
+func (p *androidPanelImpl) show() {}
+func (p *androidPanelImpl) hide() {}
+func (p *androidPanelImpl) isVisible() bool { return false }
+func (p *androidPanelImpl) setZoom(zoom float64) {}
+func (p *androidPanelImpl) getZoom() float64 { return 1.0 }
+func (p *androidPanelImpl) openDevTools() {}
+func (p *androidPanelImpl) focus() {}
+func (p *androidPanelImpl) isFocused() bool { return false }
diff --git a/v3/pkg/application/webview_panel_darwin.go b/v3/pkg/application/webview_panel_darwin.go
new file mode 100644
index 000000000..51698bdc7
--- /dev/null
+++ b/v3/pkg/application/webview_panel_darwin.go
@@ -0,0 +1,448 @@
+//go:build darwin && !ios
+
+package application
+
+/*
+#cgo CFLAGS: -mmacosx-version-min=10.13 -x objective-c
+#cgo LDFLAGS: -framework Cocoa -framework WebKit
+
+#include
+#include "Cocoa/Cocoa.h"
+#import
+
+// WebviewPanel delegate for handling messages
+@interface WebviewPanelDelegate : NSObject
+@property unsigned int panelId;
+@property unsigned int windowId;
+@property (assign) WKWebView* webView;
+@end
+
+@implementation WebviewPanelDelegate
+
+- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
+ // Handle messages from the panel's webview
+ // For now, log them - in future this could route to Go
+}
+
+- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
+ // Navigation completed callback
+ extern void panelNavigationCompleted(unsigned int panelId);
+ panelNavigationCompleted(self.panelId);
+}
+
+@end
+
+// Create a new WebviewPanel
+void* panelNew(unsigned int panelId, unsigned int windowId, void* parentWindow, int x, int y, int width, int height, bool transparent) {
+ WebviewWindow* window = (WebviewWindow*)parentWindow;
+ NSView* contentView = [window contentView];
+
+ // Calculate frame (macOS uses bottom-left origin)
+ NSRect contentBounds = [contentView bounds];
+ NSRect frame = NSMakeRect(x, contentBounds.size.height - y - height, width, height);
+
+ // Create WKWebView configuration
+ WKWebViewConfiguration* config = [[WKWebViewConfiguration alloc] init];
+ [config autorelease];
+
+ config.suppressesIncrementalRendering = true;
+ config.applicationNameForUserAgent = @"wails.io";
+
+ // Setup user content controller
+ WKUserContentController* userContentController = [WKUserContentController new];
+ [userContentController autorelease];
+
+ WebviewPanelDelegate* delegate = [[WebviewPanelDelegate alloc] init];
+ delegate.panelId = panelId;
+ delegate.windowId = windowId;
+
+ [userContentController addScriptMessageHandler:delegate name:@"external"];
+ config.userContentController = userContentController;
+
+ // Create the WKWebView
+ WKWebView* webView = [[WKWebView alloc] initWithFrame:frame configuration:config];
+ delegate.webView = webView;
+
+ // Configure webview
+ [webView setAutoresizingMask:NSViewNotSizable];
+
+ if (transparent) {
+ [webView setValue:@NO forKey:@"drawsBackground"];
+ }
+
+ // Add to parent window's content view
+ [contentView addSubview:webView];
+
+ return webView;
+}
+
+// Destroy a WebviewPanel
+void panelDestroy(void* panel) {
+ WKWebView* webView = (WKWebView*)panel;
+ [webView removeFromSuperview];
+ [webView release];
+}
+
+// Set panel bounds
+void panelSetBounds(void* panel, void* parentWindow, int x, int y, int width, int height) {
+ WKWebView* webView = (WKWebView*)panel;
+ WebviewWindow* window = (WebviewWindow*)parentWindow;
+ NSView* contentView = [window contentView];
+
+ // Calculate frame (macOS uses bottom-left origin)
+ NSRect contentBounds = [contentView bounds];
+ NSRect frame = NSMakeRect(x, contentBounds.size.height - y - height, width, height);
+
+ [webView setFrame:frame];
+}
+
+// Get panel bounds
+void panelGetBounds(void* panel, void* parentWindow, int* x, int* y, int* width, int* height) {
+ WKWebView* webView = (WKWebView*)panel;
+ WebviewWindow* window = (WebviewWindow*)parentWindow;
+ NSView* contentView = [window contentView];
+
+ NSRect frame = [webView frame];
+ NSRect contentBounds = [contentView bounds];
+
+ *x = (int)frame.origin.x;
+ *y = (int)(contentBounds.size.height - frame.origin.y - frame.size.height);
+ *width = (int)frame.size.width;
+ *height = (int)frame.size.height;
+}
+
+// Set panel z-index (bring to front or send to back)
+void panelSetZIndex(void* panel, void* parentWindow, int zIndex) {
+ WKWebView* webView = (WKWebView*)panel;
+ WebviewWindow* window = (WebviewWindow*)parentWindow;
+ NSView* contentView = [window contentView];
+
+ if (zIndex > 0) {
+ // Bring to front
+ [webView removeFromSuperview];
+ [contentView addSubview:webView positioned:NSWindowAbove relativeTo:nil];
+ } else {
+ // Send to back (but above main webview which is at index 0)
+ [webView removeFromSuperview];
+ [contentView addSubview:webView positioned:NSWindowBelow relativeTo:nil];
+ }
+}
+
+// Navigate to URL
+void panelLoadURL(void* panel, const char* url) {
+ WKWebView* webView = (WKWebView*)panel;
+ NSURL* nsURL = [NSURL URLWithString:[NSString stringWithUTF8String:url]];
+ NSURLRequest* request = [NSURLRequest requestWithURL:nsURL];
+ [webView loadRequest:request];
+}
+
+// Load HTML string
+void panelLoadHTML(void* panel, const char* html) {
+ WKWebView* webView = (WKWebView*)panel;
+ [webView loadHTMLString:[NSString stringWithUTF8String:html] baseURL:nil];
+}
+
+// Execute JavaScript
+void panelExecJS(void* panel, const char* js) {
+ WKWebView* webView = (WKWebView*)panel;
+ [webView evaluateJavaScript:[NSString stringWithUTF8String:js] completionHandler:nil];
+}
+
+// Reload
+void panelReload(void* panel) {
+ WKWebView* webView = (WKWebView*)panel;
+ [webView reload];
+}
+
+// Force reload (bypass cache)
+void panelForceReload(void* panel) {
+ WKWebView* webView = (WKWebView*)panel;
+ [webView reloadFromOrigin];
+}
+
+// Show panel
+void panelShow(void* panel) {
+ WKWebView* webView = (WKWebView*)panel;
+ [webView setHidden:NO];
+}
+
+// Hide panel
+void panelHide(void* panel) {
+ WKWebView* webView = (WKWebView*)panel;
+ [webView setHidden:YES];
+}
+
+// Check if visible
+bool panelIsVisible(void* panel) {
+ WKWebView* webView = (WKWebView*)panel;
+ return ![webView isHidden];
+}
+
+// Set zoom
+void panelSetZoom(void* panel, double zoom) {
+ WKWebView* webView = (WKWebView*)panel;
+ [webView setMagnification:zoom];
+}
+
+// Get zoom
+double panelGetZoom(void* panel) {
+ WKWebView* webView = (WKWebView*)panel;
+ return [webView magnification];
+}
+
+// Open DevTools (inspector)
+void panelOpenDevTools(void* panel) {
+ WKWebView* webView = (WKWebView*)panel;
+ // Note: Opening inspector programmatically requires private API
+ // This is a no-op for now - users can right-click -> Inspect Element if enabled
+}
+
+// Focus panel
+void panelFocus(void* panel) {
+ WKWebView* webView = (WKWebView*)panel;
+ [[webView window] makeFirstResponder:webView];
+}
+
+// Check if focused
+bool panelIsFocused(void* panel) {
+ WKWebView* webView = (WKWebView*)panel;
+ NSWindow* window = [webView window];
+ return [window firstResponder] == webView;
+}
+
+// Set background color
+void panelSetBackgroundColour(void* panel, int r, int g, int b, int a) {
+ WKWebView* webView = (WKWebView*)panel;
+ if (a == 0) {
+ [webView setValue:@NO forKey:@"drawsBackground"];
+ } else {
+ [webView setValue:[NSColor colorWithRed:r/255.0 green:g/255.0 blue:b/255.0 alpha:a/255.0] forKey:@"backgroundColor"];
+ }
+}
+
+*/
+import "C"
+import (
+ "unsafe"
+)
+
+type darwinPanelImpl struct {
+ panel *WebviewPanel
+ webview unsafe.Pointer
+ parentNSWindow unsafe.Pointer
+}
+
+func newPanelImpl(panel *WebviewPanel) webviewPanelImpl {
+ parentWindow := panel.parent
+ if parentWindow == nil || parentWindow.impl == nil {
+ return nil
+ }
+
+ darwinParent, ok := parentWindow.impl.(*macosWebviewWindow)
+ if !ok {
+ return nil
+ }
+
+ return &darwinPanelImpl{
+ panel: panel,
+ parentNSWindow: darwinParent.nsWindow,
+ }
+}
+
+//export panelNavigationCompleted
+func panelNavigationCompleted(panelId C.uint) {
+ // Find the panel and mark runtime as loaded
+ // This is called from Objective-C when navigation completes
+ // For now, we'll handle this through the panel's own tracking
+}
+
+func (p *darwinPanelImpl) create() {
+ options := p.panel.options
+
+ transparent := options.Transparent
+
+ p.webview = C.panelNew(
+ C.uint(p.panel.id),
+ C.uint(p.panel.parent.id),
+ p.parentNSWindow,
+ C.int(options.X),
+ C.int(options.Y),
+ C.int(options.Width),
+ C.int(options.Height),
+ C.bool(transparent),
+ )
+
+ // Set background colour if not transparent
+ if !transparent {
+ C.panelSetBackgroundColour(
+ p.webview,
+ C.int(options.BackgroundColour.Red),
+ C.int(options.BackgroundColour.Green),
+ C.int(options.BackgroundColour.Blue),
+ C.int(options.BackgroundColour.Alpha),
+ )
+ }
+
+ // Set initial visibility
+ if options.Visible != nil && !*options.Visible {
+ C.panelHide(p.webview)
+ }
+
+ // Set zoom if specified
+ if options.Zoom > 0 && options.Zoom != 1.0 {
+ C.panelSetZoom(p.webview, C.double(options.Zoom))
+ }
+
+ // Load initial content
+ if options.HTML != "" {
+ html := C.CString(options.HTML)
+ C.panelLoadHTML(p.webview, html)
+ C.free(unsafe.Pointer(html))
+ } else if options.URL != "" {
+ url := C.CString(options.URL)
+ C.panelLoadURL(p.webview, url)
+ C.free(unsafe.Pointer(url))
+ }
+
+ // Mark runtime as loaded (panels don't inject the wails runtime by default)
+ p.panel.markRuntimeLoaded()
+}
+
+func (p *darwinPanelImpl) destroy() {
+ if p.webview != nil {
+ C.panelDestroy(p.webview)
+ p.webview = nil
+ }
+}
+
+func (p *darwinPanelImpl) setBounds(bounds Rect) {
+ if p.webview == nil {
+ return
+ }
+ C.panelSetBounds(
+ p.webview,
+ p.parentNSWindow,
+ C.int(bounds.X),
+ C.int(bounds.Y),
+ C.int(bounds.Width),
+ C.int(bounds.Height),
+ )
+}
+
+func (p *darwinPanelImpl) bounds() Rect {
+ if p.webview == nil {
+ return Rect{}
+ }
+ var x, y, width, height C.int
+ C.panelGetBounds(p.webview, p.parentNSWindow, &x, &y, &width, &height)
+ return Rect{
+ X: int(x),
+ Y: int(y),
+ Width: int(width),
+ Height: int(height),
+ }
+}
+
+func (p *darwinPanelImpl) setZIndex(zIndex int) {
+ if p.webview == nil {
+ return
+ }
+ C.panelSetZIndex(p.webview, p.parentNSWindow, C.int(zIndex))
+}
+
+func (p *darwinPanelImpl) setURL(url string) {
+ if p.webview == nil {
+ return
+ }
+ urlStr := C.CString(url)
+ defer C.free(unsafe.Pointer(urlStr))
+ C.panelLoadURL(p.webview, urlStr)
+}
+
+func (p *darwinPanelImpl) setHTML(html string) {
+ if p.webview == nil {
+ return
+ }
+ htmlStr := C.CString(html)
+ defer C.free(unsafe.Pointer(htmlStr))
+ C.panelLoadHTML(p.webview, htmlStr)
+}
+
+func (p *darwinPanelImpl) execJS(js string) {
+ if p.webview == nil {
+ return
+ }
+ jsStr := C.CString(js)
+ defer C.free(unsafe.Pointer(jsStr))
+ C.panelExecJS(p.webview, jsStr)
+}
+
+func (p *darwinPanelImpl) reload() {
+ if p.webview == nil {
+ return
+ }
+ C.panelReload(p.webview)
+}
+
+func (p *darwinPanelImpl) forceReload() {
+ if p.webview == nil {
+ return
+ }
+ C.panelForceReload(p.webview)
+}
+
+func (p *darwinPanelImpl) show() {
+ if p.webview == nil {
+ return
+ }
+ C.panelShow(p.webview)
+}
+
+func (p *darwinPanelImpl) hide() {
+ if p.webview == nil {
+ return
+ }
+ C.panelHide(p.webview)
+}
+
+func (p *darwinPanelImpl) isVisible() bool {
+ if p.webview == nil {
+ return false
+ }
+ return bool(C.panelIsVisible(p.webview))
+}
+
+func (p *darwinPanelImpl) setZoom(zoom float64) {
+ if p.webview == nil {
+ return
+ }
+ C.panelSetZoom(p.webview, C.double(zoom))
+}
+
+func (p *darwinPanelImpl) getZoom() float64 {
+ if p.webview == nil {
+ return 1.0
+ }
+ return float64(C.panelGetZoom(p.webview))
+}
+
+func (p *darwinPanelImpl) openDevTools() {
+ if p.webview == nil {
+ return
+ }
+ C.panelOpenDevTools(p.webview)
+}
+
+func (p *darwinPanelImpl) focus() {
+ if p.webview == nil {
+ return
+ }
+ C.panelFocus(p.webview)
+}
+
+func (p *darwinPanelImpl) isFocused() bool {
+ if p.webview == nil {
+ return false
+ }
+ return bool(C.panelIsFocused(p.webview))
+}
diff --git a/v3/pkg/application/webview_panel_ios.go b/v3/pkg/application/webview_panel_ios.go
new file mode 100644
index 000000000..e4650b397
--- /dev/null
+++ b/v3/pkg/application/webview_panel_ios.go
@@ -0,0 +1,33 @@
+//go:build ios
+
+package application
+
+// iOS stub implementation for WebviewPanel
+// Panels are not yet supported on iOS
+
+type iosPanelImpl struct {
+ panel *WebviewPanel
+}
+
+func newPanelImpl(panel *WebviewPanel) webviewPanelImpl {
+ return &iosPanelImpl{panel: panel}
+}
+
+func (p *iosPanelImpl) create() {}
+func (p *iosPanelImpl) destroy() {}
+func (p *iosPanelImpl) setBounds(bounds Rect) {}
+func (p *iosPanelImpl) bounds() Rect { return Rect{} }
+func (p *iosPanelImpl) setZIndex(zIndex int) {}
+func (p *iosPanelImpl) setURL(url string) {}
+func (p *iosPanelImpl) setHTML(html string) {}
+func (p *iosPanelImpl) execJS(js string) {}
+func (p *iosPanelImpl) reload() {}
+func (p *iosPanelImpl) forceReload() {}
+func (p *iosPanelImpl) show() {}
+func (p *iosPanelImpl) hide() {}
+func (p *iosPanelImpl) isVisible() bool { return false }
+func (p *iosPanelImpl) setZoom(zoom float64) {}
+func (p *iosPanelImpl) getZoom() float64 { return 1.0 }
+func (p *iosPanelImpl) openDevTools() {}
+func (p *iosPanelImpl) focus() {}
+func (p *iosPanelImpl) isFocused() bool { return false }
diff --git a/v3/pkg/application/webview_panel_linux.go b/v3/pkg/application/webview_panel_linux.go
new file mode 100644
index 000000000..0c35ded83
--- /dev/null
+++ b/v3/pkg/application/webview_panel_linux.go
@@ -0,0 +1,380 @@
+//go:build linux && cgo && !android
+
+package application
+
+/*
+#cgo linux pkg-config: gtk+-3.0 webkit2gtk-4.1 gdk-3.0
+
+#include
+#include
+#include
+#include
+#include
+
+// Create a new WebKitWebView for a panel
+static GtkWidget* panel_new_webview() {
+ WebKitUserContentManager *manager = webkit_user_content_manager_new();
+ GtkWidget *webView = webkit_web_view_new_with_user_content_manager(manager);
+ return webView;
+}
+
+// Create a fixed container to hold the panel webview at specific position
+static GtkWidget* panel_new_fixed() {
+ return gtk_fixed_new();
+}
+
+// Add webview to fixed container at position
+static void panel_fixed_put(GtkWidget *fixed, GtkWidget *webview, int x, int y) {
+ gtk_fixed_put(GTK_FIXED(fixed), webview, x, y);
+}
+
+// Move webview in fixed container
+static void panel_fixed_move(GtkWidget *fixed, GtkWidget *webview, int x, int y) {
+ gtk_fixed_move(GTK_FIXED(fixed), webview, x, y);
+}
+
+// Set webview size
+static void panel_set_size(GtkWidget *webview, int width, int height) {
+ gtk_widget_set_size_request(webview, width, height);
+}
+
+// Load URL in webview
+static void panel_load_url(GtkWidget *webview, const char *url) {
+ webkit_web_view_load_uri(WEBKIT_WEB_VIEW(webview), url);
+}
+
+// Load HTML in webview
+static void panel_load_html(GtkWidget *webview, const char *html) {
+ webkit_web_view_load_html(WEBKIT_WEB_VIEW(webview), html, NULL);
+}
+
+// Execute JavaScript
+static void panel_exec_js(GtkWidget *webview, const char *js) {
+ webkit_web_view_run_javascript(WEBKIT_WEB_VIEW(webview), js, NULL, NULL, NULL);
+}
+
+// Reload webview
+static void panel_reload(GtkWidget *webview) {
+ webkit_web_view_reload(WEBKIT_WEB_VIEW(webview));
+}
+
+// Force reload webview (bypass cache)
+static void panel_force_reload(GtkWidget *webview) {
+ webkit_web_view_reload_bypass_cache(WEBKIT_WEB_VIEW(webview));
+}
+
+// Show webview
+static void panel_show(GtkWidget *webview) {
+ gtk_widget_show(webview);
+}
+
+// Hide webview
+static void panel_hide(GtkWidget *webview) {
+ gtk_widget_hide(webview);
+}
+
+// Check if visible
+static gboolean panel_is_visible(GtkWidget *webview) {
+ return gtk_widget_get_visible(webview);
+}
+
+// Set zoom level
+static void panel_set_zoom(GtkWidget *webview, double zoom) {
+ webkit_web_view_set_zoom_level(WEBKIT_WEB_VIEW(webview), zoom);
+}
+
+// Get zoom level
+static double panel_get_zoom(GtkWidget *webview) {
+ return webkit_web_view_get_zoom_level(WEBKIT_WEB_VIEW(webview));
+}
+
+// Open inspector
+static void panel_open_devtools(GtkWidget *webview) {
+ WebKitWebInspector *inspector = webkit_web_view_get_inspector(WEBKIT_WEB_VIEW(webview));
+ webkit_web_inspector_show(inspector);
+}
+
+// Focus webview
+static void panel_focus(GtkWidget *webview) {
+ gtk_widget_grab_focus(webview);
+}
+
+// Check if focused
+static gboolean panel_is_focused(GtkWidget *webview) {
+ return gtk_widget_has_focus(webview);
+}
+
+// Set background color
+static void panel_set_background_color(GtkWidget *webview, int r, int g, int b, int a) {
+ GdkRGBA color;
+ color.red = r / 255.0;
+ color.green = g / 255.0;
+ color.blue = b / 255.0;
+ color.alpha = a / 255.0;
+ webkit_web_view_set_background_color(WEBKIT_WEB_VIEW(webview), &color);
+}
+
+// Enable/disable devtools
+static void panel_enable_devtools(GtkWidget *webview, gboolean enable) {
+ WebKitSettings *settings = webkit_web_view_get_settings(WEBKIT_WEB_VIEW(webview));
+ webkit_settings_set_enable_developer_extras(settings, enable);
+}
+
+// Destroy the panel webview
+static void panel_destroy(GtkWidget *webview) {
+ gtk_widget_destroy(webview);
+}
+
+// Get position allocation
+static void panel_get_allocation(GtkWidget *webview, int *x, int *y, int *width, int *height) {
+ GtkAllocation alloc;
+ gtk_widget_get_allocation(webview, &alloc);
+ *x = alloc.x;
+ *y = alloc.y;
+ *width = alloc.width;
+ *height = alloc.height;
+}
+
+*/
+import "C"
+import (
+ "unsafe"
+)
+
+type linuxPanelImpl struct {
+ panel *WebviewPanel
+ webview *C.GtkWidget
+ fixed *C.GtkWidget // Fixed container to position the webview
+ parent *linuxWebviewWindow
+}
+
+func newPanelImpl(panel *WebviewPanel) webviewPanelImpl {
+ parentWindow := panel.parent
+ if parentWindow == nil || parentWindow.impl == nil {
+ return nil
+ }
+
+ linuxParent, ok := parentWindow.impl.(*linuxWebviewWindow)
+ if !ok {
+ return nil
+ }
+
+ return &linuxPanelImpl{
+ panel: panel,
+ parent: linuxParent,
+ }
+}
+
+func (p *linuxPanelImpl) create() {
+ options := p.panel.options
+
+ // Create the webview
+ p.webview = C.panel_new_webview()
+
+ // Set size
+ C.panel_set_size(p.webview, C.int(options.Width), C.int(options.Height))
+
+ // Create a fixed container if the parent's vbox doesn't have one for panels
+ // For simplicity, we'll use an overlay approach - add the webview directly to the vbox
+ // and use CSS/GTK positioning
+
+ // Actually, we need to use GtkFixed or GtkOverlay for absolute positioning
+ // For now, let's use the overlay approach with GtkFixed
+ p.fixed = C.panel_new_fixed()
+
+ // Add the webview to the fixed container at the specified position
+ C.panel_fixed_put(p.fixed, p.webview, C.int(options.X), C.int(options.Y))
+
+ // Add the fixed container to the parent's vbox (above the main webview)
+ vbox := (*C.GtkBox)(p.parent.vbox)
+ C.gtk_box_pack_start(vbox, p.fixed, 0, 0, 0) // Don't expand
+
+ // Enable devtools if in debug mode
+ debugMode := globalApplication.isDebugMode
+ devToolsEnabled := debugMode
+ if options.DevToolsEnabled != nil {
+ devToolsEnabled = *options.DevToolsEnabled
+ }
+ C.panel_enable_devtools(p.webview, C.gboolean(boolToInt(devToolsEnabled)))
+
+ // Set background color
+ if options.Transparent {
+ C.panel_set_background_color(p.webview, 0, 0, 0, 0)
+ } else {
+ C.panel_set_background_color(p.webview,
+ C.int(options.BackgroundColour.Red),
+ C.int(options.BackgroundColour.Green),
+ C.int(options.BackgroundColour.Blue),
+ C.int(options.BackgroundColour.Alpha),
+ )
+ }
+
+ // Set zoom if specified
+ if options.Zoom > 0 && options.Zoom != 1.0 {
+ C.panel_set_zoom(p.webview, C.double(options.Zoom))
+ }
+
+ // Set initial visibility
+ if options.Visible == nil || *options.Visible {
+ C.gtk_widget_show_all(p.fixed)
+ }
+
+ // Load initial content
+ if options.HTML != "" {
+ html := C.CString(options.HTML)
+ defer C.free(unsafe.Pointer(html))
+ C.panel_load_html(p.webview, html)
+ } else if options.URL != "" {
+ url := C.CString(options.URL)
+ defer C.free(unsafe.Pointer(url))
+ C.panel_load_url(p.webview, url)
+ }
+
+ // Open inspector if requested
+ if debugMode && options.OpenInspectorOnStartup {
+ C.panel_open_devtools(p.webview)
+ }
+
+ // Mark runtime as loaded
+ p.panel.markRuntimeLoaded()
+}
+
+func boolToInt(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+}
+
+func (p *linuxPanelImpl) destroy() {
+ if p.fixed != nil {
+ C.panel_destroy(p.fixed)
+ p.fixed = nil
+ p.webview = nil
+ }
+}
+
+func (p *linuxPanelImpl) setBounds(bounds Rect) {
+ if p.webview == nil || p.fixed == nil {
+ return
+ }
+ C.panel_fixed_move(p.fixed, p.webview, C.int(bounds.X), C.int(bounds.Y))
+ C.panel_set_size(p.webview, C.int(bounds.Width), C.int(bounds.Height))
+}
+
+func (p *linuxPanelImpl) bounds() Rect {
+ if p.webview == nil {
+ return Rect{}
+ }
+ var x, y, width, height C.int
+ C.panel_get_allocation(p.webview, &x, &y, &width, &height)
+ return Rect{
+ X: int(x),
+ Y: int(y),
+ Width: int(width),
+ Height: int(height),
+ }
+}
+
+func (p *linuxPanelImpl) setZIndex(zIndex int) {
+ // GTK doesn't have a direct z-index concept
+ // We could use gtk_box_reorder_child to change ordering
+ // For now, this is a no-op
+}
+
+func (p *linuxPanelImpl) setURL(url string) {
+ if p.webview == nil {
+ return
+ }
+ urlStr := C.CString(url)
+ defer C.free(unsafe.Pointer(urlStr))
+ C.panel_load_url(p.webview, urlStr)
+}
+
+func (p *linuxPanelImpl) setHTML(html string) {
+ if p.webview == nil {
+ return
+ }
+ htmlStr := C.CString(html)
+ defer C.free(unsafe.Pointer(htmlStr))
+ C.panel_load_html(p.webview, htmlStr)
+}
+
+func (p *linuxPanelImpl) execJS(js string) {
+ if p.webview == nil {
+ return
+ }
+ jsStr := C.CString(js)
+ defer C.free(unsafe.Pointer(jsStr))
+ C.panel_exec_js(p.webview, jsStr)
+}
+
+func (p *linuxPanelImpl) reload() {
+ if p.webview == nil {
+ return
+ }
+ C.panel_reload(p.webview)
+}
+
+func (p *linuxPanelImpl) forceReload() {
+ if p.webview == nil {
+ return
+ }
+ C.panel_force_reload(p.webview)
+}
+
+func (p *linuxPanelImpl) show() {
+ if p.fixed == nil {
+ return
+ }
+ C.gtk_widget_show_all(p.fixed)
+}
+
+func (p *linuxPanelImpl) hide() {
+ if p.fixed == nil {
+ return
+ }
+ C.gtk_widget_hide(p.fixed)
+}
+
+func (p *linuxPanelImpl) isVisible() bool {
+ if p.fixed == nil {
+ return false
+ }
+ return C.gtk_widget_get_visible(p.fixed) != 0
+}
+
+func (p *linuxPanelImpl) setZoom(zoom float64) {
+ if p.webview == nil {
+ return
+ }
+ C.panel_set_zoom(p.webview, C.double(zoom))
+}
+
+func (p *linuxPanelImpl) getZoom() float64 {
+ if p.webview == nil {
+ return 1.0
+ }
+ return float64(C.panel_get_zoom(p.webview))
+}
+
+func (p *linuxPanelImpl) openDevTools() {
+ if p.webview == nil {
+ return
+ }
+ C.panel_open_devtools(p.webview)
+}
+
+func (p *linuxPanelImpl) focus() {
+ if p.webview == nil {
+ return
+ }
+ C.panel_focus(p.webview)
+}
+
+func (p *linuxPanelImpl) isFocused() bool {
+ if p.webview == nil {
+ return false
+ }
+ return C.panel_is_focused(p.webview) != 0
+}
diff --git a/v3/pkg/application/webview_panel_options.go b/v3/pkg/application/webview_panel_options.go
new file mode 100644
index 000000000..6e8395144
--- /dev/null
+++ b/v3/pkg/application/webview_panel_options.go
@@ -0,0 +1,102 @@
+package application
+
+// WebviewPanelOptions contains options for creating a WebviewPanel.
+// Panels are absolutely positioned webview containers within a window.
+type WebviewPanelOptions struct {
+ // Name is a unique identifier for the panel within its parent window.
+ // If empty, a name will be auto-generated.
+ Name string
+
+ // X is the horizontal position of the panel relative to the parent window's content area.
+ // Uses CSS pixels (device-independent).
+ X int
+
+ // Y is the vertical position of the panel relative to the parent window's content area.
+ // Uses CSS pixels (device-independent).
+ Y int
+
+ // Width is the width of the panel in CSS pixels.
+ // If 0, defaults to 400.
+ Width int
+
+ // Height is the height of the panel in CSS pixels.
+ // If 0, defaults to 300.
+ Height int
+
+ // ZIndex controls the stacking order of panels within the window.
+ // Higher values appear on top of lower values.
+ // The main webview has an effective ZIndex of 0.
+ // Default: 1
+ ZIndex int
+
+ // URL is the initial URL to load in the panel.
+ // Can be a local path (e.g., "/panel.html") or external URL (e.g., "https://example.com").
+ URL string
+
+ // HTML is the initial HTML content to display in the panel.
+ // If both URL and HTML are set, URL takes precedence.
+ HTML string
+
+ // JS is JavaScript to execute after the page loads.
+ JS string
+
+ // CSS is CSS to inject into the panel.
+ CSS string
+
+ // Visible controls whether the panel is initially visible.
+ // Default: true
+ Visible *bool
+
+ // DevToolsEnabled enables the developer tools for this panel.
+ // Default: follows the parent window's setting
+ DevToolsEnabled *bool
+
+ // Zoom is the initial zoom level of the panel.
+ // Default: 1.0
+ Zoom float64
+
+ // BackgroundColour is the background color of the panel.
+ BackgroundColour RGBA
+
+ // Frameless removes the default styling/border around the panel.
+ // Default: false
+ Frameless bool
+
+ // Transparent makes the panel background transparent.
+ // Default: false
+ Transparent bool
+
+ // Anchor specifies how the panel should be anchored to the window edges.
+ // When anchored, the panel maintains its distance from the specified edges
+ // when the window is resized.
+ Anchor AnchorType
+
+ // OpenInspectorOnStartup will open the inspector when the panel is first shown.
+ OpenInspectorOnStartup bool
+}
+
+// AnchorType defines how a panel is anchored within its parent window.
+// Multiple anchors can be combined using bitwise OR.
+type AnchorType uint8
+
+const (
+ // AnchorNone - panel uses absolute positioning only (default)
+ AnchorNone AnchorType = 0
+
+ // AnchorTop - panel maintains distance from top edge
+ AnchorTop AnchorType = 1 << iota
+
+ // AnchorBottom - panel maintains distance from bottom edge
+ AnchorBottom
+
+ // AnchorLeft - panel maintains distance from left edge
+ AnchorLeft
+
+ // AnchorRight - panel maintains distance from right edge
+ AnchorRight
+
+ // AnchorFill - panel fills the entire window (anchored to all edges)
+ AnchorFill AnchorType = AnchorTop | AnchorBottom | AnchorLeft | AnchorRight
+)
+
+// Note: Rect is defined in screenmanager.go
diff --git a/v3/pkg/application/webview_panel_windows.go b/v3/pkg/application/webview_panel_windows.go
new file mode 100644
index 000000000..0ddd57eb5
--- /dev/null
+++ b/v3/pkg/application/webview_panel_windows.go
@@ -0,0 +1,395 @@
+//go:build windows
+
+package application
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/wailsapp/go-webview2/pkg/edge"
+ "github.com/wailsapp/wails/v3/internal/assetserver"
+ "github.com/wailsapp/wails/v3/pkg/w32"
+)
+
+type windowsPanelImpl struct {
+ panel *WebviewPanel
+ parent *windowsWebviewWindow
+ chromium *edge.Chromium
+ hwnd w32.HWND // Child window handle to host the WebView2
+
+ // Track navigation state
+ navigationCompleted bool
+}
+
+func newPanelImpl(panel *WebviewPanel) webviewPanelImpl {
+ parentWindow := panel.parent
+ if parentWindow == nil || parentWindow.impl == nil {
+ return nil
+ }
+
+ windowsParent, ok := parentWindow.impl.(*windowsWebviewWindow)
+ if !ok {
+ return nil
+ }
+
+ return &windowsPanelImpl{
+ panel: panel,
+ parent: windowsParent,
+ }
+}
+
+func (p *windowsPanelImpl) create() {
+ options := p.panel.options
+
+ // Create a child window to host the WebView2
+ // We use WS_CHILD style to make it a child of the parent window
+ style := uint(w32.WS_CHILD | w32.WS_CLIPSIBLINGS)
+ if options.Visible == nil || *options.Visible {
+ style |= w32.WS_VISIBLE
+ }
+
+ // Convert DIP coordinates to physical pixels
+ bounds := DipToPhysicalRect(Rect{
+ X: options.X,
+ Y: options.Y,
+ Width: options.Width,
+ Height: options.Height,
+ })
+
+ // Create the child window
+ p.hwnd = w32.CreateWindowEx(
+ 0,
+ w32.MustStringToUTF16Ptr("STATIC"), // Using STATIC class for the container
+ nil,
+ style,
+ bounds.X,
+ bounds.Y,
+ bounds.Width,
+ bounds.Height,
+ p.parent.hwnd,
+ 0,
+ w32.GetModuleHandle(""),
+ nil,
+ )
+
+ if p.hwnd == 0 {
+ globalApplication.error("failed to create panel child window")
+ return
+ }
+
+ // Setup WebView2 (Chromium)
+ p.setupChromium()
+}
+
+func (p *windowsPanelImpl) setupChromium() {
+ p.chromium = edge.NewChromium()
+
+ if globalApplication.options.ErrorHandler != nil {
+ p.chromium.SetErrorCallback(globalApplication.options.ErrorHandler)
+ }
+
+ // Configure chromium
+ p.chromium.DataPath = globalApplication.options.Windows.WebviewUserDataPath
+ p.chromium.BrowserPath = globalApplication.options.Windows.WebviewBrowserPath
+
+ // Set up callbacks
+ p.chromium.MessageCallback = p.processMessage
+ p.chromium.NavigationCompletedCallback = p.navigationCompletedCallback
+
+ // Embed the WebView2 into our child window
+ p.chromium.Embed(p.hwnd)
+ p.chromium.Resize()
+
+ // Configure settings
+ settings, err := p.chromium.GetSettings()
+ if err != nil {
+ globalApplication.error("failed to get chromium settings: %v", err)
+ return
+ }
+
+ debugMode := globalApplication.isDebugMode
+
+ // Disable context menus unless in debug mode or explicitly enabled
+ devToolsEnabled := debugMode
+ if p.panel.options.DevToolsEnabled != nil {
+ devToolsEnabled = *p.panel.options.DevToolsEnabled
+ }
+ err = settings.PutAreDefaultContextMenusEnabled(devToolsEnabled)
+ if err != nil {
+ globalApplication.error("failed to configure context menus: %v", err)
+ }
+
+ err = settings.PutAreDevToolsEnabled(devToolsEnabled)
+ if err != nil {
+ globalApplication.error("failed to configure devtools: %v", err)
+ }
+
+ // Set zoom if specified
+ if p.panel.options.Zoom > 0 && p.panel.options.Zoom != 1.0 {
+ p.chromium.PutZoomFactor(p.panel.options.Zoom)
+ }
+
+ // Set background colour
+ if p.panel.options.Transparent {
+ p.chromium.SetBackgroundColour(0, 0, 0, 0)
+ } else {
+ p.chromium.SetBackgroundColour(
+ p.panel.options.BackgroundColour.Red,
+ p.panel.options.BackgroundColour.Green,
+ p.panel.options.BackgroundColour.Blue,
+ p.panel.options.BackgroundColour.Alpha,
+ )
+ }
+
+ // Navigate to initial content
+ if p.panel.options.HTML != "" {
+ p.loadHTMLWithScripts()
+ } else if p.panel.options.URL != "" {
+ startURL, err := assetserver.GetStartURL(p.panel.options.URL)
+ if err != nil {
+ globalApplication.error("failed to get start URL: %v", err)
+ return
+ }
+ p.chromium.Navigate(startURL)
+ }
+
+ // Open inspector if requested
+ if debugMode && p.panel.options.OpenInspectorOnStartup {
+ p.chromium.OpenDevToolsWindow()
+ }
+}
+
+func (p *windowsPanelImpl) loadHTMLWithScripts() {
+ var script string
+ if p.panel.options.JS != "" {
+ script = p.panel.options.JS
+ }
+ if p.panel.options.CSS != "" {
+ script += fmt.Sprintf(
+ "; addEventListener(\"DOMContentLoaded\", (event) => { document.head.appendChild(document.createElement('style')).innerHTML=\"%s\"; });",
+ strings.ReplaceAll(p.panel.options.CSS, `"`, `\"`),
+ )
+ }
+ if script != "" {
+ p.chromium.Init(script)
+ }
+ p.chromium.NavigateToString(p.panel.options.HTML)
+}
+
+func (p *windowsPanelImpl) processMessage(message string, sender *edge.ICoreWebView2, args *edge.ICoreWebView2WebMessageReceivedEventArgs) {
+ // For now, just log panel messages
+ // In future, we could route these to the parent window or handle panel-specific messages
+ globalApplication.debug("Panel message received", "panel", p.panel.name, "message", message)
+}
+
+func (p *windowsPanelImpl) navigationCompletedCallback(sender *edge.ICoreWebView2, args *edge.ICoreWebView2NavigationCompletedEventArgs) {
+ p.navigationCompleted = true
+
+ // Execute any pending JS
+ if p.panel.options.JS != "" && p.panel.options.HTML == "" {
+ p.execJS(p.panel.options.JS)
+ }
+ if p.panel.options.CSS != "" && p.panel.options.HTML == "" {
+ js := fmt.Sprintf(
+ "(function() { var style = document.createElement('style'); style.appendChild(document.createTextNode('%s')); document.head.appendChild(style); })();",
+ strings.ReplaceAll(p.panel.options.CSS, "'", "\\'"),
+ )
+ p.execJS(js)
+ }
+
+ // Mark runtime as loaded
+ p.panel.markRuntimeLoaded()
+}
+
+func (p *windowsPanelImpl) destroy() {
+ if p.hwnd != 0 {
+ w32.DestroyWindow(p.hwnd)
+ p.hwnd = 0
+ }
+ p.chromium = nil
+}
+
+func (p *windowsPanelImpl) setBounds(bounds Rect) {
+ if p.hwnd == 0 {
+ return
+ }
+
+ // Convert DIP to physical pixels
+ physicalBounds := DipToPhysicalRect(bounds)
+
+ // Move and resize the child window
+ w32.SetWindowPos(
+ p.hwnd,
+ 0,
+ physicalBounds.X,
+ physicalBounds.Y,
+ physicalBounds.Width,
+ physicalBounds.Height,
+ w32.SWP_NOZORDER|w32.SWP_NOACTIVATE,
+ )
+
+ // Resize the WebView2 to fill the child window
+ if p.chromium != nil {
+ p.chromium.Resize()
+ }
+}
+
+func (p *windowsPanelImpl) bounds() Rect {
+ if p.hwnd == 0 {
+ return Rect{}
+ }
+
+ rect := w32.GetWindowRect(p.hwnd)
+ if rect == nil {
+ return Rect{}
+ }
+
+ // Get parent window position to calculate relative position
+ parentRect := w32.GetWindowRect(p.parent.hwnd)
+ if parentRect == nil {
+ return Rect{}
+ }
+
+ // Calculate position relative to parent's client area
+ var clientPoint w32.POINT
+ clientPoint.X = 0
+ clientPoint.Y = 0
+ parentClientX, parentClientY := w32.ClientToScreen(p.parent.hwnd, 0, 0)
+
+ physicalBounds := Rect{
+ X: int(rect.Left) - parentClientX,
+ Y: int(rect.Top) - parentClientY,
+ Width: int(rect.Right - rect.Left),
+ Height: int(rect.Bottom - rect.Top),
+ }
+
+ return PhysicalToDipRect(physicalBounds)
+}
+
+func (p *windowsPanelImpl) setZIndex(zIndex int) {
+ if p.hwnd == 0 {
+ return
+ }
+
+ // Use SetWindowPos to change z-order
+ // Higher zIndex = on top
+ var insertAfter uintptr
+ if zIndex > 0 {
+ insertAfter = w32.HWND_TOP
+ } else {
+ insertAfter = w32.HWND_BOTTOM
+ }
+
+ w32.SetWindowPos(
+ p.hwnd,
+ insertAfter,
+ 0, 0, 0, 0,
+ w32.SWP_NOMOVE|w32.SWP_NOSIZE|w32.SWP_NOACTIVATE,
+ )
+}
+
+func (p *windowsPanelImpl) setURL(url string) {
+ if p.chromium == nil {
+ return
+ }
+ startURL, err := assetserver.GetStartURL(url)
+ if err != nil {
+ globalApplication.error("failed to get start URL: %v", err)
+ return
+ }
+ p.navigationCompleted = false
+ p.chromium.Navigate(startURL)
+}
+
+func (p *windowsPanelImpl) setHTML(html string) {
+ if p.chromium == nil {
+ return
+ }
+ p.chromium.NavigateToString(html)
+}
+
+func (p *windowsPanelImpl) execJS(js string) {
+ if p.chromium == nil {
+ return
+ }
+ globalApplication.dispatchOnMainThread(func() {
+ p.chromium.Eval(js)
+ })
+}
+
+func (p *windowsPanelImpl) reload() {
+ p.execJS("window.location.reload();")
+}
+
+func (p *windowsPanelImpl) forceReload() {
+ // WebView2 doesn't have a cache-bypass reload, so just reload normally
+ p.reload()
+}
+
+func (p *windowsPanelImpl) show() {
+ if p.hwnd == 0 {
+ return
+ }
+ w32.ShowWindow(p.hwnd, w32.SW_SHOW)
+}
+
+func (p *windowsPanelImpl) hide() {
+ if p.hwnd == 0 {
+ return
+ }
+ w32.ShowWindow(p.hwnd, w32.SW_HIDE)
+}
+
+func (p *windowsPanelImpl) isVisible() bool {
+ if p.hwnd == 0 {
+ return false
+ }
+ style := uint32(w32.GetWindowLong(p.hwnd, w32.GWL_STYLE))
+ return style&w32.WS_VISIBLE != 0
+}
+
+func (p *windowsPanelImpl) setZoom(zoom float64) {
+ if p.chromium == nil {
+ return
+ }
+ p.chromium.PutZoomFactor(zoom)
+}
+
+func (p *windowsPanelImpl) getZoom() float64 {
+ if p.chromium == nil {
+ return 1.0
+ }
+ controller := p.chromium.GetController()
+ if controller == nil {
+ return 1.0
+ }
+ factor, err := controller.GetZoomFactor()
+ if err != nil {
+ return 1.0
+ }
+ return factor
+}
+
+func (p *windowsPanelImpl) openDevTools() {
+ if p.chromium == nil {
+ return
+ }
+ p.chromium.OpenDevToolsWindow()
+}
+
+func (p *windowsPanelImpl) focus() {
+ if p.hwnd == 0 {
+ return
+ }
+ w32.SetFocus(p.hwnd)
+ if p.chromium != nil {
+ p.chromium.Focus()
+ }
+}
+
+func (p *windowsPanelImpl) isFocused() bool {
+ if p.hwnd == 0 {
+ return false
+ }
+ return w32.GetFocus() == p.hwnd
+}
diff --git a/v3/pkg/application/webview_window.go b/v3/pkg/application/webview_window.go
index dbf7eebcc..9e11bc952 100644
--- a/v3/pkg/application/webview_window.go
+++ b/v3/pkg/application/webview_window.go
@@ -173,6 +173,10 @@ type WebviewWindow struct {
// unconditionallyClose marks the window to be unconditionally closed (atomic)
unconditionallyClose uint32
+
+ // Embedded panels management
+ panels map[uint]*WebviewPanel
+ panelsLock sync.RWMutex
}
func (w *WebviewWindow) SetMenu(menu *Menu) {
@@ -289,6 +293,7 @@ func NewWindow(options WebviewWindowOptions) *WebviewWindow {
eventListeners: make(map[uint][]*WindowEventListener),
eventHooks: make(map[uint][]*WindowEventListener),
menuBindings: make(map[string]*MenuItem),
+ panels: make(map[uint]*WebviewPanel),
}
result.setupEventMapping()
@@ -402,6 +407,9 @@ func (w *WebviewWindow) Run() {
w.impl = newWindowImpl(w)
InvokeSync(w.impl.run)
+
+ // Start any panels that were added before the window was run
+ w.runPanels()
}
// SetAlwaysOnTop sets the window to be always on top.
@@ -1528,3 +1536,125 @@ func (w *WebviewWindow) SnapAssist() {
}
InvokeSync(w.impl.snapAssist)
}
+
+// ============================================================================
+// Panel Management Methods
+// ============================================================================
+
+// NewPanel creates a new WebviewPanel with the given options and adds it to this window.
+// The panel is a secondary webview that can be positioned anywhere within the window.
+// This is similar to Electron's BrowserView or the deprecated webview tag.
+//
+// Example:
+//
+// panel := window.NewPanel(application.WebviewPanelOptions{
+// X: 0,
+// Y: 0,
+// Width: 300,
+// Height: 400,
+// URL: "https://example.com",
+// })
+func (w *WebviewWindow) NewPanel(options WebviewPanelOptions) *WebviewPanel {
+ panel := NewPanel(options)
+ panel.parent = w
+
+ w.panelsLock.Lock()
+ w.panels[panel.id] = panel
+ w.panelsLock.Unlock()
+
+ // If window is already running, start the panel immediately
+ if w.impl != nil && !w.isDestroyed() {
+ InvokeSync(panel.run)
+ }
+
+ return panel
+}
+
+// GetPanel returns a panel by its name, or nil if not found.
+func (w *WebviewWindow) GetPanel(name string) *WebviewPanel {
+ w.panelsLock.RLock()
+ defer w.panelsLock.RUnlock()
+
+ for _, panel := range w.panels {
+ if panel.name == name {
+ return panel
+ }
+ }
+ return nil
+}
+
+// GetPanelByID returns a panel by its ID, or nil if not found.
+func (w *WebviewWindow) GetPanelByID(id uint) *WebviewPanel {
+ w.panelsLock.RLock()
+ defer w.panelsLock.RUnlock()
+ return w.panels[id]
+}
+
+// GetPanels returns all panels attached to this window.
+func (w *WebviewWindow) GetPanels() []*WebviewPanel {
+ w.panelsLock.RLock()
+ defer w.panelsLock.RUnlock()
+
+ panels := make([]*WebviewPanel, 0, len(w.panels))
+ for _, panel := range w.panels {
+ panels = append(panels, panel)
+ }
+ return panels
+}
+
+// RemovePanel removes a panel from this window by its name.
+// Returns true if the panel was found and removed.
+func (w *WebviewWindow) RemovePanel(name string) bool {
+ panel := w.GetPanel(name)
+ if panel == nil {
+ return false
+ }
+ panel.Destroy()
+ return true
+}
+
+// RemovePanelByID removes a panel from this window by its ID.
+// Returns true if the panel was found and removed.
+func (w *WebviewWindow) RemovePanelByID(id uint) bool {
+ panel := w.GetPanelByID(id)
+ if panel == nil {
+ return false
+ }
+ panel.Destroy()
+ return true
+}
+
+// removePanel is called by WebviewPanel.Destroy() to remove itself from the parent
+func (w *WebviewWindow) removePanel(id uint) {
+ w.panelsLock.Lock()
+ defer w.panelsLock.Unlock()
+ delete(w.panels, id)
+}
+
+// runPanels starts all panels that haven't been started yet.
+// This is called after the window's impl is created.
+func (w *WebviewWindow) runPanels() {
+ w.panelsLock.RLock()
+ defer w.panelsLock.RUnlock()
+
+ for _, panel := range w.panels {
+ if panel.impl == nil {
+ panel.run()
+ }
+ }
+}
+
+// destroyAllPanels destroys all panels in this window.
+// This is called when the window is closing.
+func (w *WebviewWindow) destroyAllPanels() {
+ w.panelsLock.Lock()
+ panels := make([]*WebviewPanel, 0, len(w.panels))
+ for _, panel := range w.panels {
+ panels = append(panels, panel)
+ }
+ w.panelsLock.Unlock()
+
+ for _, panel := range panels {
+ panel.Destroy()
+ }
+}
From 48e93ea67f46b916a8d5f181ab37e74b74fbd437 Mon Sep 17 00:00:00 2001
From: WCY-dt <834421194@qq.com>
Date: Wed, 14 Jan 2026 18:09:52 +0800
Subject: [PATCH 2/7] fix(v3): Address PR review feedback for WebviewPanel
- Fix SonarCloud issues:
- Add comments for empty stub functions on Android/iOS
- Prefix unused parameters with underscore
- Refactor processPanelMethod to use handler map (reduce cognitive complexity)
- Use export...from syntax for Panel re-export in TypeScript
- Fix CodeRabbit/Copilot review issues:
- Add autorelease for WebviewPanelDelegate in Darwin implementation
- Add thread-safe access to pendingJS and runtimeLoaded with runtimeLock mutex
- Add synchronization to panel.run() method
- Call destroyAllPanels() in WindowClosing event handler
- Call chromium.ShuttingDown() before DestroyWindow on Windows
- Fix CSS escaping to handle backslashes and newlines
- Remove redundant mutex around atomic operation in getNextPanelID
- Fix runPanels to not hold lock during panel.run()
- Fix documentation: HTML takes precedence over URL when both are set
---
.../desktop/@wailsio/runtime/src/index.ts | 4 +-
v3/pkg/application/messageprocessor_panel.go | 252 ++++++++++--------
v3/pkg/application/webview_panel.go | 30 ++-
v3/pkg/application/webview_panel_android.go | 92 +++++--
v3/pkg/application/webview_panel_darwin.go | 3 +-
v3/pkg/application/webview_panel_ios.go | 92 +++++--
v3/pkg/application/webview_panel_linux.go | 2 +-
v3/pkg/application/webview_panel_options.go | 2 +-
v3/pkg/application/webview_panel_windows.go | 21 +-
v3/pkg/application/webview_window.go | 14 +-
10 files changed, 348 insertions(+), 164 deletions(-)
diff --git a/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts b/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts
index e31aeff1c..9e1bc9420 100644
--- a/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts
+++ b/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts
@@ -26,10 +26,11 @@ import * as Flags from "./flags.js";
import * as Screens from "./screens.js";
import * as System from "./system.js";
import * as IOS from "./ios.js";
-import { Panel } from "./panel.js";
import Window, { handleDragEnter, handleDragLeave, handleDragOver } from "./window.js";
import * as WML from "./wml.js";
+export { Panel } from "./panel.js";
+
export {
Application,
Browser,
@@ -38,7 +39,6 @@ export {
Dialogs,
Events,
Flags,
- Panel,
Screens,
System,
IOS,
diff --git a/v3/pkg/application/messageprocessor_panel.go b/v3/pkg/application/messageprocessor_panel.go
index 1198d2040..5306dd774 100644
--- a/v3/pkg/application/messageprocessor_panel.go
+++ b/v3/pkg/application/messageprocessor_panel.go
@@ -49,6 +49,31 @@ var panelMethodNames = map[int]string{
PanelName: "Name",
}
+// panelMethodHandler handles a specific panel method
+type panelMethodHandler func(panel *WebviewPanel, args *MapArgs) (any, error)
+
+// panelMethodHandlers maps method IDs to their handlers
+var panelMethodHandlers = map[int]panelMethodHandler{
+ PanelSetBounds: handlePanelSetBounds,
+ PanelGetBounds: handlePanelGetBounds,
+ PanelSetZIndex: handlePanelSetZIndex,
+ PanelSetURL: handlePanelSetURL,
+ PanelSetHTML: handlePanelSetHTML,
+ PanelExecJS: handlePanelExecJS,
+ PanelReload: handlePanelReload,
+ PanelForceReload: handlePanelForceReload,
+ PanelShow: handlePanelShow,
+ PanelHide: handlePanelHide,
+ PanelIsVisible: handlePanelIsVisible,
+ PanelSetZoom: handlePanelSetZoom,
+ PanelGetZoom: handlePanelGetZoom,
+ PanelFocus: handlePanelFocus,
+ PanelIsFocused: handlePanelIsFocused,
+ PanelOpenDevTools: handlePanelOpenDevTools,
+ PanelDestroy: handlePanelDestroy,
+ PanelName: handlePanelName,
+}
+
func (m *MessageProcessor) processPanelMethod(
req *RuntimeRequest,
window Window,
@@ -67,10 +92,9 @@ func (m *MessageProcessor) processPanelMethod(
return nil, errs.NewInvalidRuntimeCallErrorf("panel name is required")
}
- // Get the panel
+ // Get the panel by name or ID
panel := ww.GetPanel(*panelName)
if panel == nil {
- // Try by ID
panelID := args.UInt("panelId")
if panelID != nil && *panelID > 0 {
panel = ww.GetPanelByID(uint(*panelID))
@@ -80,108 +104,128 @@ func (m *MessageProcessor) processPanelMethod(
return nil, errs.NewInvalidRuntimeCallErrorf("panel not found: %s", *panelName)
}
- switch req.Method {
- case PanelSetBounds:
- x := args.Int("x")
- y := args.Int("y")
- width := args.Int("width")
- height := args.Int("height")
- if x == nil || y == nil || width == nil || height == nil {
- return nil, errs.NewInvalidRuntimeCallErrorf("x, y, width, and height are required")
- }
- panel.SetBounds(Rect{X: *x, Y: *y, Width: *width, Height: *height})
- return unit, nil
-
- case PanelGetBounds:
- bounds := panel.Bounds()
- return map[string]interface{}{
- "x": bounds.X,
- "y": bounds.Y,
- "width": bounds.Width,
- "height": bounds.Height,
- }, nil
-
- case PanelSetZIndex:
- zIndex := args.Int("zIndex")
- if zIndex == nil {
- return nil, errs.NewInvalidRuntimeCallErrorf("zIndex is required")
- }
- panel.SetZIndex(*zIndex)
- return unit, nil
-
- case PanelSetURL:
- url := args.String("url")
- if url == nil {
- return nil, errs.NewInvalidRuntimeCallErrorf("url is required")
- }
- panel.SetURL(*url)
- return unit, nil
-
- case PanelSetHTML:
- html := args.String("html")
- if html == nil {
- return nil, errs.NewInvalidRuntimeCallErrorf("html is required")
- }
- panel.SetHTML(*html)
- return unit, nil
-
- case PanelExecJS:
- js := args.String("js")
- if js == nil {
- return nil, errs.NewInvalidRuntimeCallErrorf("js is required")
- }
- panel.ExecJS(*js)
- return unit, nil
-
- case PanelReload:
- panel.Reload()
- return unit, nil
-
- case PanelForceReload:
- panel.ForceReload()
- return unit, nil
-
- case PanelShow:
- panel.Show()
- return unit, nil
-
- case PanelHide:
- panel.Hide()
- return unit, nil
-
- case PanelIsVisible:
- return panel.IsVisible(), nil
-
- case PanelSetZoom:
- zoom := args.Float64("zoom")
- if zoom == nil {
- return nil, errs.NewInvalidRuntimeCallErrorf("zoom is required")
- }
- panel.SetZoom(*zoom)
- return unit, nil
-
- case PanelGetZoom:
- return panel.GetZoom(), nil
-
- case PanelFocus:
- panel.Focus()
- return unit, nil
-
- case PanelIsFocused:
- return panel.IsFocused(), nil
-
- case PanelOpenDevTools:
- panel.OpenDevTools()
- return unit, nil
-
- case PanelDestroy:
- panel.Destroy()
- return unit, nil
-
- case PanelName:
- return panel.Name(), nil
-
- default:
+ // Look up and execute the handler
+ handler, ok := panelMethodHandlers[req.Method]
+ if !ok {
return nil, fmt.Errorf("unknown panel method: %d", req.Method)
}
+ return handler(panel, args)
+}
+
+func handlePanelSetBounds(panel *WebviewPanel, args *MapArgs) (any, error) {
+ x := args.Int("x")
+ y := args.Int("y")
+ width := args.Int("width")
+ height := args.Int("height")
+ if x == nil || y == nil || width == nil || height == nil {
+ return nil, errs.NewInvalidRuntimeCallErrorf("x, y, width, and height are required")
+ }
+ panel.SetBounds(Rect{X: *x, Y: *y, Width: *width, Height: *height})
+ return unit, nil
+}
+
+func handlePanelGetBounds(panel *WebviewPanel, _ *MapArgs) (any, error) {
+ bounds := panel.Bounds()
+ return map[string]interface{}{
+ "x": bounds.X,
+ "y": bounds.Y,
+ "width": bounds.Width,
+ "height": bounds.Height,
+ }, nil
+}
+
+func handlePanelSetZIndex(panel *WebviewPanel, args *MapArgs) (any, error) {
+ zIndex := args.Int("zIndex")
+ if zIndex == nil {
+ return nil, errs.NewInvalidRuntimeCallErrorf("zIndex is required")
+ }
+ panel.SetZIndex(*zIndex)
+ return unit, nil
+}
+
+func handlePanelSetURL(panel *WebviewPanel, args *MapArgs) (any, error) {
+ url := args.String("url")
+ if url == nil {
+ return nil, errs.NewInvalidRuntimeCallErrorf("url is required")
+ }
+ panel.SetURL(*url)
+ return unit, nil
+}
+
+func handlePanelSetHTML(panel *WebviewPanel, args *MapArgs) (any, error) {
+ html := args.String("html")
+ if html == nil {
+ return nil, errs.NewInvalidRuntimeCallErrorf("html is required")
+ }
+ panel.SetHTML(*html)
+ return unit, nil
+}
+
+func handlePanelExecJS(panel *WebviewPanel, args *MapArgs) (any, error) {
+ js := args.String("js")
+ if js == nil {
+ return nil, errs.NewInvalidRuntimeCallErrorf("js is required")
+ }
+ panel.ExecJS(*js)
+ return unit, nil
+}
+
+func handlePanelReload(panel *WebviewPanel, _ *MapArgs) (any, error) {
+ panel.Reload()
+ return unit, nil
+}
+
+func handlePanelForceReload(panel *WebviewPanel, _ *MapArgs) (any, error) {
+ panel.ForceReload()
+ return unit, nil
+}
+
+func handlePanelShow(panel *WebviewPanel, _ *MapArgs) (any, error) {
+ panel.Show()
+ return unit, nil
+}
+
+func handlePanelHide(panel *WebviewPanel, _ *MapArgs) (any, error) {
+ panel.Hide()
+ return unit, nil
+}
+
+func handlePanelIsVisible(panel *WebviewPanel, _ *MapArgs) (any, error) {
+ return panel.IsVisible(), nil
+}
+
+func handlePanelSetZoom(panel *WebviewPanel, args *MapArgs) (any, error) {
+ zoom := args.Float64("zoom")
+ if zoom == nil {
+ return nil, errs.NewInvalidRuntimeCallErrorf("zoom is required")
+ }
+ panel.SetZoom(*zoom)
+ return unit, nil
+}
+
+func handlePanelGetZoom(panel *WebviewPanel, _ *MapArgs) (any, error) {
+ return panel.GetZoom(), nil
+}
+
+func handlePanelFocus(panel *WebviewPanel, _ *MapArgs) (any, error) {
+ panel.Focus()
+ return unit, nil
+}
+
+func handlePanelIsFocused(panel *WebviewPanel, _ *MapArgs) (any, error) {
+ return panel.IsFocused(), nil
+}
+
+func handlePanelOpenDevTools(panel *WebviewPanel, _ *MapArgs) (any, error) {
+ panel.OpenDevTools()
+ return unit, nil
+}
+
+func handlePanelDestroy(panel *WebviewPanel, _ *MapArgs) (any, error) {
+ panel.Destroy()
+ return unit, nil
+}
+
+func handlePanelName(panel *WebviewPanel, _ *MapArgs) (any, error) {
+ return panel.Name(), nil
}
diff --git a/v3/pkg/application/webview_panel.go b/v3/pkg/application/webview_panel.go
index e9d6a5c86..7db8b4396 100644
--- a/v3/pkg/application/webview_panel.go
+++ b/v3/pkg/application/webview_panel.go
@@ -42,11 +42,8 @@ type webviewPanelImpl interface {
}
var panelID uint32
-var panelIDLock sync.Mutex
func getNextPanelID() uint {
- panelIDLock.Lock()
- defer panelIDLock.Unlock()
return uint(atomic.AddUint32(&panelID, 1))
}
@@ -65,9 +62,10 @@ type WebviewPanel struct {
destroyed bool
destroyedLock sync.RWMutex
- // Track if runtime has been loaded
+ // Track if runtime has been loaded (protected by runtimeLock)
runtimeLoaded bool
pendingJS []string
+ runtimeLock sync.Mutex
}
// NewPanel creates a new WebviewPanel with the given options.
@@ -223,12 +221,16 @@ func (p *WebviewPanel) ExecJS(js string) {
if p.impl == nil || p.isDestroyed() {
return
}
+
+ p.runtimeLock.Lock()
if p.runtimeLoaded {
+ p.runtimeLock.Unlock()
InvokeSync(func() {
p.impl.execJS(js)
})
} else {
p.pendingJS = append(p.pendingJS, js)
+ p.runtimeLock.Unlock()
}
}
@@ -345,21 +347,31 @@ func (p *WebviewPanel) isDestroyed() bool {
// run initializes the platform-specific implementation
// This is called by the parent window when the panel is added
func (p *WebviewPanel) run() {
- if p.impl != nil {
+ p.destroyedLock.Lock()
+ if p.impl != nil || p.destroyed {
+ p.destroyedLock.Unlock()
return
}
p.impl = newPanelImpl(p)
+ p.destroyedLock.Unlock()
+
InvokeSync(p.impl.create)
}
// markRuntimeLoaded is called when the runtime JavaScript has been loaded
func (p *WebviewPanel) markRuntimeLoaded() {
+ p.runtimeLock.Lock()
p.runtimeLoaded = true
- // Execute any pending JavaScript
- for _, js := range p.pendingJS {
- p.ExecJS(js)
- }
+ pendingJS := p.pendingJS
p.pendingJS = nil
+ p.runtimeLock.Unlock()
+
+ // Execute any pending JavaScript outside the lock
+ for _, js := range pendingJS {
+ InvokeSync(func() {
+ p.impl.execJS(js)
+ })
+ }
}
// =========================================================================
diff --git a/v3/pkg/application/webview_panel_android.go b/v3/pkg/application/webview_panel_android.go
index 8e99aac7d..003f22e33 100644
--- a/v3/pkg/application/webview_panel_android.go
+++ b/v3/pkg/application/webview_panel_android.go
@@ -3,7 +3,8 @@
package application
// Android stub implementation for WebviewPanel
-// Panels are not yet supported on Android
+// Panels are not yet supported on Android.
+// All methods are no-ops until Android platform support is implemented.
type androidPanelImpl struct {
panel *WebviewPanel
@@ -13,21 +14,74 @@ func newPanelImpl(panel *WebviewPanel) webviewPanelImpl {
return &androidPanelImpl{panel: panel}
}
-func (p *androidPanelImpl) create() {}
-func (p *androidPanelImpl) destroy() {}
-func (p *androidPanelImpl) setBounds(bounds Rect) {}
-func (p *androidPanelImpl) bounds() Rect { return Rect{} }
-func (p *androidPanelImpl) setZIndex(zIndex int) {}
-func (p *androidPanelImpl) setURL(url string) {}
-func (p *androidPanelImpl) setHTML(html string) {}
-func (p *androidPanelImpl) execJS(js string) {}
-func (p *androidPanelImpl) reload() {}
-func (p *androidPanelImpl) forceReload() {}
-func (p *androidPanelImpl) show() {}
-func (p *androidPanelImpl) hide() {}
-func (p *androidPanelImpl) isVisible() bool { return false }
-func (p *androidPanelImpl) setZoom(zoom float64) {}
-func (p *androidPanelImpl) getZoom() float64 { return 1.0 }
-func (p *androidPanelImpl) openDevTools() {}
-func (p *androidPanelImpl) focus() {}
-func (p *androidPanelImpl) isFocused() bool { return false }
+func (p *androidPanelImpl) create() {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) destroy() {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) setBounds(_ Rect) {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) bounds() Rect {
+ return Rect{}
+}
+
+func (p *androidPanelImpl) setZIndex(_ int) {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) setURL(_ string) {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) setHTML(_ string) {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) execJS(_ string) {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) reload() {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) forceReload() {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) show() {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) hide() {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) isVisible() bool {
+ return false
+}
+
+func (p *androidPanelImpl) setZoom(_ float64) {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) getZoom() float64 {
+ return 1.0
+}
+
+func (p *androidPanelImpl) openDevTools() {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) focus() {
+ // Not implemented on Android
+}
+
+func (p *androidPanelImpl) isFocused() bool {
+ return false
+}
diff --git a/v3/pkg/application/webview_panel_darwin.go b/v3/pkg/application/webview_panel_darwin.go
index 51698bdc7..503813319 100644
--- a/v3/pkg/application/webview_panel_darwin.go
+++ b/v3/pkg/application/webview_panel_darwin.go
@@ -53,6 +53,7 @@ void* panelNew(unsigned int panelId, unsigned int windowId, void* parentWindow,
[userContentController autorelease];
WebviewPanelDelegate* delegate = [[WebviewPanelDelegate alloc] init];
+ [delegate autorelease];
delegate.panelId = panelId;
delegate.windowId = windowId;
@@ -250,7 +251,7 @@ func newPanelImpl(panel *WebviewPanel) webviewPanelImpl {
}
//export panelNavigationCompleted
-func panelNavigationCompleted(panelId C.uint) {
+func panelNavigationCompleted(_ C.uint) {
// Find the panel and mark runtime as loaded
// This is called from Objective-C when navigation completes
// For now, we'll handle this through the panel's own tracking
diff --git a/v3/pkg/application/webview_panel_ios.go b/v3/pkg/application/webview_panel_ios.go
index e4650b397..c363218d6 100644
--- a/v3/pkg/application/webview_panel_ios.go
+++ b/v3/pkg/application/webview_panel_ios.go
@@ -3,7 +3,8 @@
package application
// iOS stub implementation for WebviewPanel
-// Panels are not yet supported on iOS
+// Panels are not yet supported on iOS.
+// All methods are no-ops until iOS platform support is implemented.
type iosPanelImpl struct {
panel *WebviewPanel
@@ -13,21 +14,74 @@ func newPanelImpl(panel *WebviewPanel) webviewPanelImpl {
return &iosPanelImpl{panel: panel}
}
-func (p *iosPanelImpl) create() {}
-func (p *iosPanelImpl) destroy() {}
-func (p *iosPanelImpl) setBounds(bounds Rect) {}
-func (p *iosPanelImpl) bounds() Rect { return Rect{} }
-func (p *iosPanelImpl) setZIndex(zIndex int) {}
-func (p *iosPanelImpl) setURL(url string) {}
-func (p *iosPanelImpl) setHTML(html string) {}
-func (p *iosPanelImpl) execJS(js string) {}
-func (p *iosPanelImpl) reload() {}
-func (p *iosPanelImpl) forceReload() {}
-func (p *iosPanelImpl) show() {}
-func (p *iosPanelImpl) hide() {}
-func (p *iosPanelImpl) isVisible() bool { return false }
-func (p *iosPanelImpl) setZoom(zoom float64) {}
-func (p *iosPanelImpl) getZoom() float64 { return 1.0 }
-func (p *iosPanelImpl) openDevTools() {}
-func (p *iosPanelImpl) focus() {}
-func (p *iosPanelImpl) isFocused() bool { return false }
+func (p *iosPanelImpl) create() {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) destroy() {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) setBounds(_ Rect) {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) bounds() Rect {
+ return Rect{}
+}
+
+func (p *iosPanelImpl) setZIndex(_ int) {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) setURL(_ string) {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) setHTML(_ string) {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) execJS(_ string) {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) reload() {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) forceReload() {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) show() {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) hide() {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) isVisible() bool {
+ return false
+}
+
+func (p *iosPanelImpl) setZoom(_ float64) {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) getZoom() float64 {
+ return 1.0
+}
+
+func (p *iosPanelImpl) openDevTools() {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) focus() {
+ // Not implemented on iOS
+}
+
+func (p *iosPanelImpl) isFocused() bool {
+ return false
+}
diff --git a/v3/pkg/application/webview_panel_linux.go b/v3/pkg/application/webview_panel_linux.go
index 0c35ded83..2ae933553 100644
--- a/v3/pkg/application/webview_panel_linux.go
+++ b/v3/pkg/application/webview_panel_linux.go
@@ -276,7 +276,7 @@ func (p *linuxPanelImpl) bounds() Rect {
}
}
-func (p *linuxPanelImpl) setZIndex(zIndex int) {
+func (p *linuxPanelImpl) setZIndex(_ int) {
// GTK doesn't have a direct z-index concept
// We could use gtk_box_reorder_child to change ordering
// For now, this is a no-op
diff --git a/v3/pkg/application/webview_panel_options.go b/v3/pkg/application/webview_panel_options.go
index 6e8395144..049a30a7e 100644
--- a/v3/pkg/application/webview_panel_options.go
+++ b/v3/pkg/application/webview_panel_options.go
@@ -34,7 +34,7 @@ type WebviewPanelOptions struct {
URL string
// HTML is the initial HTML content to display in the panel.
- // If both URL and HTML are set, URL takes precedence.
+ // If both URL and HTML are set, HTML takes precedence.
HTML string
// JS is JavaScript to execute after the page loads.
diff --git a/v3/pkg/application/webview_panel_windows.go b/v3/pkg/application/webview_panel_windows.go
index 0ddd57eb5..2e1d65b3d 100644
--- a/v3/pkg/application/webview_panel_windows.go
+++ b/v3/pkg/application/webview_panel_windows.go
@@ -165,9 +165,14 @@ func (p *windowsPanelImpl) loadHTMLWithScripts() {
script = p.panel.options.JS
}
if p.panel.options.CSS != "" {
+ // Escape CSS for safe injection into JavaScript string
+ escapedCSS := strings.ReplaceAll(p.panel.options.CSS, `\`, `\\`)
+ escapedCSS = strings.ReplaceAll(escapedCSS, `"`, `\"`)
+ escapedCSS = strings.ReplaceAll(escapedCSS, "\n", `\n`)
+ escapedCSS = strings.ReplaceAll(escapedCSS, "\r", `\r`)
script += fmt.Sprintf(
"; addEventListener(\"DOMContentLoaded\", (event) => { document.head.appendChild(document.createElement('style')).innerHTML=\"%s\"; });",
- strings.ReplaceAll(p.panel.options.CSS, `"`, `\"`),
+ escapedCSS,
)
}
if script != "" {
@@ -176,13 +181,13 @@ func (p *windowsPanelImpl) loadHTMLWithScripts() {
p.chromium.NavigateToString(p.panel.options.HTML)
}
-func (p *windowsPanelImpl) processMessage(message string, sender *edge.ICoreWebView2, args *edge.ICoreWebView2WebMessageReceivedEventArgs) {
+func (p *windowsPanelImpl) processMessage(message string, _ *edge.ICoreWebView2, _ *edge.ICoreWebView2WebMessageReceivedEventArgs) {
// For now, just log panel messages
// In future, we could route these to the parent window or handle panel-specific messages
globalApplication.debug("Panel message received", "panel", p.panel.name, "message", message)
}
-func (p *windowsPanelImpl) navigationCompletedCallback(sender *edge.ICoreWebView2, args *edge.ICoreWebView2NavigationCompletedEventArgs) {
+func (p *windowsPanelImpl) navigationCompletedCallback(_ *edge.ICoreWebView2, _ *edge.ICoreWebView2NavigationCompletedEventArgs) {
p.navigationCompleted = true
// Execute any pending JS
@@ -190,9 +195,14 @@ func (p *windowsPanelImpl) navigationCompletedCallback(sender *edge.ICoreWebView
p.execJS(p.panel.options.JS)
}
if p.panel.options.CSS != "" && p.panel.options.HTML == "" {
+ // Escape CSS for safe injection into JavaScript string
+ escapedCSS := strings.ReplaceAll(p.panel.options.CSS, `\`, `\\`)
+ escapedCSS = strings.ReplaceAll(escapedCSS, `'`, `\'`)
+ escapedCSS = strings.ReplaceAll(escapedCSS, "\n", `\n`)
+ escapedCSS = strings.ReplaceAll(escapedCSS, "\r", `\r`)
js := fmt.Sprintf(
"(function() { var style = document.createElement('style'); style.appendChild(document.createTextNode('%s')); document.head.appendChild(style); })();",
- strings.ReplaceAll(p.panel.options.CSS, "'", "\\'"),
+ escapedCSS,
)
p.execJS(js)
}
@@ -202,6 +212,9 @@ func (p *windowsPanelImpl) navigationCompletedCallback(sender *edge.ICoreWebView
}
func (p *windowsPanelImpl) destroy() {
+ if p.chromium != nil {
+ p.chromium.ShuttingDown()
+ }
if p.hwnd != 0 {
w32.DestroyWindow(p.hwnd)
p.hwnd = 0
diff --git a/v3/pkg/application/webview_window.go b/v3/pkg/application/webview_window.go
index 9e11bc952..c07acdfc8 100644
--- a/v3/pkg/application/webview_window.go
+++ b/v3/pkg/application/webview_window.go
@@ -298,9 +298,10 @@ func NewWindow(options WebviewWindowOptions) *WebviewWindow {
result.setupEventMapping()
- // Listen for window closing events and de
+ // Listen for window closing events and cleanup panels
result.OnWindowEvent(events.Common.WindowClosing, func(event *WindowEvent) {
atomic.StoreUint32(&result.unconditionallyClose, 1)
+ InvokeSync(result.destroyAllPanels)
InvokeSync(result.markAsDestroyed)
InvokeSync(result.impl.close)
globalApplication.Window.Remove(result.id)
@@ -1634,14 +1635,19 @@ func (w *WebviewWindow) removePanel(id uint) {
// runPanels starts all panels that haven't been started yet.
// This is called after the window's impl is created.
func (w *WebviewWindow) runPanels() {
+ // Collect panels under lock, then run them outside the lock
w.panelsLock.RLock()
- defer w.panelsLock.RUnlock()
-
+ panels := make([]*WebviewPanel, 0, len(w.panels))
for _, panel := range w.panels {
if panel.impl == nil {
- panel.run()
+ panels = append(panels, panel)
}
}
+ w.panelsLock.RUnlock()
+
+ for _, panel := range panels {
+ panel.run()
+ }
}
// destroyAllPanels destroys all panels in this window.
From 0432be1f4dae7d5610de421dcba053a46d32f9cc Mon Sep 17 00:00:00 2001
From: WCY-dt <834421194@qq.com>
Date: Wed, 14 Jan 2026 19:10:06 +0800
Subject: [PATCH 3/7] fix: address CodeRabbit review feedback for WebviewPanel
- Darwin: Fix panelNavigationCompleted to actually look up panel and
call markRuntimeLoaded() when navigation completes
- Darwin: Pass both windowId and panelId to navigation callback
- Darwin: Remove premature markRuntimeLoaded() call in create() - now
properly waits for navigation to complete
- Windows: Remove unused clientPoint variable in bounds() function
- Document z-index binary limitation (top/bottom only) on both platforms
- Example: Move panel creation to WindowShow event for proper initialization
---
v3/examples/webview-panel/main.go | 73 +++++++++++----------
v3/pkg/application/webview_panel_darwin.go | 32 ++++++---
v3/pkg/application/webview_panel_windows.go | 10 +--
3 files changed, 67 insertions(+), 48 deletions(-)
diff --git a/v3/examples/webview-panel/main.go b/v3/examples/webview-panel/main.go
index c52ad18b8..4b254d53c 100644
--- a/v3/examples/webview-panel/main.go
+++ b/v3/examples/webview-panel/main.go
@@ -4,6 +4,7 @@ import (
"log"
"github.com/wailsapp/wails/v3/pkg/application"
+ "github.com/wailsapp/wails/v3/pkg/events"
)
func main() {
@@ -72,17 +73,18 @@ func main() {
})
// =====================================================================
- // Example 1: Using explicit coordinates (traditional approach)
+ // Create panels after the window is shown
+ // This ensures the window is fully initialized and visible before creating panels
// =====================================================================
-
- // Create a sidebar panel on the left with explicit positioning
- sidebarPanel := window.NewPanel(application.WebviewPanelOptions{
- Name: "sidebar",
- X: 0,
- Y: 50, // Start below the 50px header
- Width: 220,
- Height: 650,
- HTML: `
+ window.OnWindowEvent(events.Common.WindowShow, func(*application.WindowEvent) {
+ // Create a sidebar panel on the left with explicit positioning
+ sidebarPanel := window.NewPanel(application.WebviewPanelOptions{
+ Name: "sidebar",
+ X: 0,
+ Y: 50, // Start below the 50px header
+ Width: 220,
+ Height: 650,
+ HTML: `
+
+
+
+
+
+
+
+ Embedded Panel: https://wails.io
+
+
+
+
+
+
Resize the window to see the panel respond dynamically
+
Loading...
+
+
+
+
+
+
diff --git a/v3/examples/webview-panel/main.go b/v3/examples/webview-panel/main.go
index 4b254d53c..3cf2f756e 100644
--- a/v3/examples/webview-panel/main.go
+++ b/v3/examples/webview-panel/main.go
@@ -1,199 +1,91 @@
package main
import (
+ "embed"
"log"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)
+//go:embed assets/*
+var assets embed.FS
+
+// App struct holds application state
+type App struct {
+ panel *application.WebviewPanel
+}
+
+// SwitchPanel switches the embedded panel to a different URL
+func (a *App) SwitchPanel(url string) {
+ if a.panel != nil {
+ log.Printf("🔄 Switching panel to: %s", url)
+ a.panel.SetURL(url)
+ }
+}
+
func main() {
+ appState := &App{}
+
app := application.New(application.Options{
Name: "WebviewPanel Demo",
- Description: "Demonstrates embedding multiple webview panels within a single window",
+ Description: "Demonstrates embedding multiple webviews with switching capability",
+ Assets: application.AssetOptions{
+ Handler: application.BundledAssetFileServer(assets),
+ },
+ Services: []application.Service{
+ application.NewService(appState),
+ },
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: true,
},
})
- // Create the main window with a simple header
+ // Create the main window with our custom UI
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
- Title: "WebviewPanel Demo - Multi-Panel Layout",
+ Title: "WebviewPanel Demo - Embedded Webviews",
Width: 1200,
- Height: 700,
+ Height: 800,
BackgroundType: application.BackgroundTypeSolid,
- BackgroundColour: application.NewRGB(45, 45, 45),
- HTML: `
-
-
- WebviewPanel Demo
-
-
-
-
-
-`,
+ BackgroundColour: application.NewRGB(26, 26, 46),
+ URL: "/index.html",
})
- // =====================================================================
- // Create panels after the window is shown
- // This ensures the window is fully initialized and visible before creating panels
- // =====================================================================
+ // Create the embedded panel after the window is shown
window.OnWindowEvent(events.Common.WindowShow, func(*application.WindowEvent) {
- // Create a sidebar panel on the left with explicit positioning
- sidebarPanel := window.NewPanel(application.WebviewPanelOptions{
- Name: "sidebar",
- X: 0,
- Y: 50, // Start below the 50px header
- Width: 220,
- Height: 650,
- HTML: `
-
-
-
-
-
-
-
Navigation
-
- - 🏠 Dashboard
- - 📊 Analytics
- - 📁 Projects
- - 👥 Team
- - ⚙️ Settings
-
-
-
-
Favorites
-
- - ⭐ Starred
- - 🕐 Recent
- - 📌 Pinned
-
-
-
-`,
- BackgroundColour: application.NewRGB(30, 30, 46),
- Visible: boolPtr(true),
- ZIndex: 1,
+ // Based on actual HTML measurements:
+ // - Header: 41px height (with 15px/25px padding already included)
+ // - Content area: padding 20px
+ // - Panel container: 1142×591, border 1px
+ // - Panel should fit inside container: 1140×589
+ const (
+ headerHeight = 41 + 15*2 // 41px header + vertical padding
+ contentPadding = 20
+ containerBorder = 1
+ // Panel container inner size (container size minus borders)
+ panelContainerWidth = 1140 // 1142 - 2
+ panelContainerHeight = 589 // 591 - 2
+ )
+
+ // Panel position: content padding + container border
+ panelX := contentPadding + containerBorder
+ panelY := headerHeight + contentPadding + containerBorder
+
+ // Create a panel positioned inside the content area
+ // Using AnchorFill means the panel will maintain these margins when the window is resized
+ appState.panel = window.NewPanel(application.WebviewPanelOptions{
+ Name: "embedded-content",
+ URL: "https://wails.io",
+ X: panelX,
+ Y: panelY,
+ Width: panelContainerWidth,
+ Height: panelContainerHeight,
+ Anchor: application.AnchorFill, // Maintain margins on all sides when resizing
+ Visible: boolPtr(true),
+ DevToolsEnabled: boolPtr(true),
})
-
- // =====================================================================
- // Example 2: Content panel showing an external website
- // This demonstrates loading external URLs in an embedded webview
- // =====================================================================
-
- contentPanel := window.NewPanel(application.WebviewPanelOptions{
- Name: "content",
- X: 220, // Right of sidebar
- Y: 50, // Below header
- Width: 980,
- Height: 650,
- URL: "https://wails.io", // External website
- DevToolsEnabled: boolPtr(true),
- Visible: boolPtr(true),
- BackgroundColour: application.NewRGB(255, 255, 255),
- ZIndex: 1,
- })
-
- // Log panel creation
- log.Printf("✅ Created sidebar panel: %s (ID: %d)", sidebarPanel.Name(), sidebarPanel.ID())
- log.Printf("✅ Created content panel: %s (ID: %d)", contentPanel.Name(), contentPanel.ID())
})
- // =====================================================================
- // Alternative: Using layout helper methods (commented examples)
- // =====================================================================
- //
- // // Create a panel and dock it to the left
- // sidebar := window.NewPanel(opts).DockLeft(200)
- //
- // // Create a panel and fill space beside another
- // content := window.NewPanel(opts).FillBeside(sidebar, "right")
- //
- // // Create a panel that fills the entire window
- // fullscreen := window.NewPanel(opts).FillWindow()
- //
- // // Dock panels to different edges
- // toolbar := window.NewPanel(opts).DockTop(50)
- // statusBar := window.NewPanel(opts).DockBottom(30)
- //
-
// Run the application
err := app.Run()
if err != nil {
diff --git a/v3/pkg/application/messageprocessor_panel.go b/v3/pkg/application/messageprocessor_panel.go
index 5306dd774..4cd0d6c14 100644
--- a/v3/pkg/application/messageprocessor_panel.go
+++ b/v3/pkg/application/messageprocessor_panel.go
@@ -12,20 +12,18 @@ const (
PanelGetBounds = 1
PanelSetZIndex = 2
PanelSetURL = 3
- PanelSetHTML = 4
- PanelExecJS = 5
- PanelReload = 6
- PanelForceReload = 7
- PanelShow = 8
- PanelHide = 9
- PanelIsVisible = 10
- PanelSetZoom = 11
- PanelGetZoom = 12
- PanelFocus = 13
- PanelIsFocused = 14
- PanelOpenDevTools = 15
- PanelDestroy = 16
- PanelName = 17
+ PanelReload = 4
+ PanelForceReload = 5
+ PanelShow = 6
+ PanelHide = 7
+ PanelIsVisible = 8
+ PanelSetZoom = 9
+ PanelGetZoom = 10
+ PanelFocus = 11
+ PanelIsFocused = 12
+ PanelOpenDevTools = 13
+ PanelDestroy = 14
+ PanelName = 15
)
var panelMethodNames = map[int]string{
@@ -33,8 +31,6 @@ var panelMethodNames = map[int]string{
PanelGetBounds: "GetBounds",
PanelSetZIndex: "SetZIndex",
PanelSetURL: "SetURL",
- PanelSetHTML: "SetHTML",
- PanelExecJS: "ExecJS",
PanelReload: "Reload",
PanelForceReload: "ForceReload",
PanelShow: "Show",
@@ -58,8 +54,6 @@ var panelMethodHandlers = map[int]panelMethodHandler{
PanelGetBounds: handlePanelGetBounds,
PanelSetZIndex: handlePanelSetZIndex,
PanelSetURL: handlePanelSetURL,
- PanelSetHTML: handlePanelSetHTML,
- PanelExecJS: handlePanelExecJS,
PanelReload: handlePanelReload,
PanelForceReload: handlePanelForceReload,
PanelShow: handlePanelShow,
@@ -152,24 +146,6 @@ func handlePanelSetURL(panel *WebviewPanel, args *MapArgs) (any, error) {
return unit, nil
}
-func handlePanelSetHTML(panel *WebviewPanel, args *MapArgs) (any, error) {
- html := args.String("html")
- if html == nil {
- return nil, errs.NewInvalidRuntimeCallErrorf("html is required")
- }
- panel.SetHTML(*html)
- return unit, nil
-}
-
-func handlePanelExecJS(panel *WebviewPanel, args *MapArgs) (any, error) {
- js := args.String("js")
- if js == nil {
- return nil, errs.NewInvalidRuntimeCallErrorf("js is required")
- }
- panel.ExecJS(*js)
- return unit, nil
-}
-
func handlePanelReload(panel *WebviewPanel, _ *MapArgs) (any, error) {
panel.Reload()
return unit, nil
diff --git a/v3/pkg/application/webview_panel.go b/v3/pkg/application/webview_panel.go
index 7db8b4396..addee0efa 100644
--- a/v3/pkg/application/webview_panel.go
+++ b/v3/pkg/application/webview_panel.go
@@ -19,8 +19,6 @@ type webviewPanelImpl interface {
// Content
setURL(url string)
- setHTML(html string)
- execJS(js string)
reload()
forceReload()
@@ -62,10 +60,11 @@ type WebviewPanel struct {
destroyed bool
destroyedLock sync.RWMutex
- // Track if runtime has been loaded (protected by runtimeLock)
- runtimeLoaded bool
- pendingJS []string
- runtimeLock sync.Mutex
+ // Original window size when panel was created (for anchor calculations)
+ originalWindowWidth int
+ originalWindowHeight int
+ // Original panel bounds (for anchor calculations)
+ originalBounds Rect
}
// NewPanel creates a new WebviewPanel with the given options.
@@ -95,10 +94,19 @@ func NewPanel(options WebviewPanelOptions) *WebviewPanel {
options.Visible = &visible
}
+ // Store original bounds for anchor calculations
+ originalBounds := Rect{
+ X: options.X,
+ Y: options.Y,
+ Width: options.Width,
+ Height: options.Height,
+ }
+
return &WebviewPanel{
- id: id,
- name: options.Name,
- options: options,
+ id: id,
+ name: options.Name,
+ options: options,
+ originalBounds: originalBounds,
}
}
@@ -205,35 +213,6 @@ func (p *WebviewPanel) URL() string {
return p.options.URL
}
-// SetHTML sets the HTML content of the panel
-func (p *WebviewPanel) SetHTML(html string) *WebviewPanel {
- p.options.HTML = html
- if p.impl != nil && !p.isDestroyed() {
- InvokeSync(func() {
- p.impl.setHTML(html)
- })
- }
- return p
-}
-
-// ExecJS executes JavaScript in the panel's context
-func (p *WebviewPanel) ExecJS(js string) {
- if p.impl == nil || p.isDestroyed() {
- return
- }
-
- p.runtimeLock.Lock()
- if p.runtimeLoaded {
- p.runtimeLock.Unlock()
- InvokeSync(func() {
- p.impl.execJS(js)
- })
- } else {
- p.pendingJS = append(p.pendingJS, js)
- p.runtimeLock.Unlock()
- }
-}
-
// Reload reloads the current page
func (p *WebviewPanel) Reload() {
if p.impl != nil && !p.isDestroyed() {
@@ -347,31 +326,40 @@ func (p *WebviewPanel) isDestroyed() bool {
// run initializes the platform-specific implementation
// This is called by the parent window when the panel is added
func (p *WebviewPanel) run() {
+ globalApplication.debug("[Panel] run() called", "panelID", p.id, "panelName", p.name)
+
p.destroyedLock.Lock()
if p.impl != nil || p.destroyed {
+ globalApplication.debug("[Panel] run() skipped - impl already exists or destroyed",
+ "panelID", p.id, "hasImpl", p.impl != nil, "destroyed", p.destroyed)
p.destroyedLock.Unlock()
return
}
+
+ // Check parent window state before creating impl
+ if p.parent == nil {
+ globalApplication.error("[Panel] run() failed - parent window is nil", "panelID", p.id)
+ p.destroyedLock.Unlock()
+ return
+ }
+ if p.parent.impl == nil {
+ globalApplication.error("[Panel] run() failed - parent window impl is nil", "panelID", p.id, "windowID", p.parent.id)
+ p.destroyedLock.Unlock()
+ return
+ }
+
+ globalApplication.debug("[Panel] Creating platform impl", "panelID", p.id, "parentWindowID", p.parent.id)
p.impl = newPanelImpl(p)
p.destroyedLock.Unlock()
- InvokeSync(p.impl.create)
-}
-
-// markRuntimeLoaded is called when the runtime JavaScript has been loaded
-func (p *WebviewPanel) markRuntimeLoaded() {
- p.runtimeLock.Lock()
- p.runtimeLoaded = true
- pendingJS := p.pendingJS
- p.pendingJS = nil
- p.runtimeLock.Unlock()
-
- // Execute any pending JavaScript outside the lock
- for _, js := range pendingJS {
- InvokeSync(func() {
- p.impl.execJS(js)
- })
+ if p.impl == nil {
+ globalApplication.error("[Panel] newPanelImpl returned nil", "panelID", p.id)
+ return
}
+
+ globalApplication.debug("[Panel] Calling impl.create()", "panelID", p.id)
+ InvokeSync(p.impl.create)
+ globalApplication.debug("[Panel] impl.create() completed", "panelID", p.id)
}
// =========================================================================
@@ -475,3 +463,97 @@ func (p *WebviewPanel) FillBeside(refPanel *WebviewPanel, direction string) *Web
return p.SetBounds(bounds)
}
+
+// =========================================================================
+// Anchor/Responsive Layout Methods
+// =========================================================================
+
+// initializeAnchor stores the original window size for anchor calculations.
+// This is called when the panel is first attached to a window.
+func (p *WebviewPanel) initializeAnchor() {
+ if p.parent == nil {
+ return
+ }
+ p.originalWindowWidth, p.originalWindowHeight = p.parent.Size()
+}
+
+// handleWindowResize recalculates the panel's bounds based on its anchor settings.
+// This is called automatically when the parent window is resized.
+func (p *WebviewPanel) handleWindowResize(newWindowWidth, newWindowHeight int) {
+ if p.isDestroyed() || p.options.Anchor == AnchorNone {
+ return
+ }
+
+ newBounds := p.calculateAnchoredBounds(newWindowWidth, newWindowHeight)
+ p.SetBounds(newBounds)
+}
+
+// calculateAnchoredBounds computes the new bounds based on anchor settings.
+func (p *WebviewPanel) calculateAnchoredBounds(newWindowWidth, newWindowHeight int) Rect {
+ anchor := p.options.Anchor
+ orig := p.originalBounds
+ origWinW := p.originalWindowWidth
+ origWinH := p.originalWindowHeight
+
+ // If original window size was not recorded, use current bounds
+ if origWinW == 0 || origWinH == 0 {
+ return Rect{
+ X: p.options.X,
+ Y: p.options.Y,
+ Width: p.options.Width,
+ Height: p.options.Height,
+ }
+ }
+
+ // Calculate distances from edges
+ distanceFromRight := origWinW - (orig.X + orig.Width)
+ distanceFromBottom := origWinH - (orig.Y + orig.Height)
+
+ newX := orig.X
+ newY := orig.Y
+ newWidth := orig.Width
+ newHeight := orig.Height
+
+ // Handle horizontal anchoring
+ hasLeft := anchor.HasAnchor(AnchorLeft)
+ hasRight := anchor.HasAnchor(AnchorRight)
+
+ if hasLeft && hasRight {
+ // Anchored to both sides - stretch horizontally
+ newX = orig.X
+ newWidth = newWindowWidth - orig.X - distanceFromRight
+ } else if hasRight {
+ // Anchored to right only - maintain distance from right
+ newX = newWindowWidth - distanceFromRight - orig.Width
+ }
+ // If hasLeft only or no horizontal anchor, X stays the same
+
+ // Handle vertical anchoring
+ hasTop := anchor.HasAnchor(AnchorTop)
+ hasBottom := anchor.HasAnchor(AnchorBottom)
+
+ if hasTop && hasBottom {
+ // Anchored to both sides - stretch vertically
+ newY = orig.Y
+ newHeight = newWindowHeight - orig.Y - distanceFromBottom
+ } else if hasBottom {
+ // Anchored to bottom only - maintain distance from bottom
+ newY = newWindowHeight - distanceFromBottom - orig.Height
+ }
+ // If hasTop only or no vertical anchor, Y stays the same
+
+ // Ensure minimum dimensions
+ if newWidth < 1 {
+ newWidth = 1
+ }
+ if newHeight < 1 {
+ newHeight = 1
+ }
+
+ return Rect{
+ X: newX,
+ Y: newY,
+ Width: newWidth,
+ Height: newHeight,
+ }
+}
diff --git a/v3/pkg/application/webview_panel_android.go b/v3/pkg/application/webview_panel_android.go
index 003f22e33..47c4658e2 100644
--- a/v3/pkg/application/webview_panel_android.go
+++ b/v3/pkg/application/webview_panel_android.go
@@ -38,14 +38,6 @@ func (p *androidPanelImpl) setURL(_ string) {
// Not implemented on Android
}
-func (p *androidPanelImpl) setHTML(_ string) {
- // Not implemented on Android
-}
-
-func (p *androidPanelImpl) execJS(_ string) {
- // Not implemented on Android
-}
-
func (p *androidPanelImpl) reload() {
// Not implemented on Android
}
diff --git a/v3/pkg/application/webview_panel_darwin.go b/v3/pkg/application/webview_panel_darwin.go
index 482882a0b..bb120ebff 100644
--- a/v3/pkg/application/webview_panel_darwin.go
+++ b/v3/pkg/application/webview_panel_darwin.go
@@ -140,18 +140,6 @@ void panelLoadURL(void* panel, const char* url) {
[webView loadRequest:request];
}
-// Load HTML string
-void panelLoadHTML(void* panel, const char* html) {
- WKWebView* webView = (WKWebView*)panel;
- [webView loadHTMLString:[NSString stringWithUTF8String:html] baseURL:nil];
-}
-
-// Execute JavaScript
-void panelExecJS(void* panel, const char* js) {
- WKWebView* webView = (WKWebView*)panel;
- [webView evaluateJavaScript:[NSString stringWithUTF8String:js] completionHandler:nil];
-}
-
// Reload
void panelReload(void* panel) {
WKWebView* webView = (WKWebView*)panel;
@@ -255,22 +243,8 @@ func newPanelImpl(panel *WebviewPanel) webviewPanelImpl {
//export panelNavigationCompleted
func panelNavigationCompleted(windowID C.uint, panelID C.uint) {
- // Find the window first
- window, ok := globalApplication.Window.GetByID(uint(windowID))
- if !ok || window == nil {
- globalApplication.debug("panelNavigationCompleted: could not find window", "windowID", uint(windowID))
- return
- }
-
- // Find the panel in the window
- panel := window.GetPanelByID(uint(panelID))
- if panel == nil {
- globalApplication.debug("panelNavigationCompleted: could not find panel", "panelID", uint(panelID))
- return
- }
-
- // Mark runtime as loaded so that pending JS can execute
- panel.markRuntimeLoaded()
+ // Navigation completed callback - could be used for future functionality
+ globalApplication.debug("panelNavigationCompleted", "windowID", uint(windowID), "panelID", uint(panelID))
}
func (p *darwinPanelImpl) create() {
@@ -310,19 +284,22 @@ func (p *darwinPanelImpl) create() {
C.panelSetZoom(p.webview, C.double(options.Zoom))
}
- // Load initial content
- if options.HTML != "" {
- html := C.CString(options.HTML)
- C.panelLoadHTML(p.webview, html)
- C.free(unsafe.Pointer(html))
- } else if options.URL != "" {
+ // Navigate to initial URL
+ if options.URL != "" {
+ // TODO: Add support for custom headers when WKWebView supports it
+ if len(options.Headers) > 0 {
+ globalApplication.debug("[Panel-Darwin] Custom headers specified (not yet supported)",
+ "panelID", p.panel.id,
+ "headers", options.Headers)
+ }
+
url := C.CString(options.URL)
C.panelLoadURL(p.webview, url)
C.free(unsafe.Pointer(url))
}
// Note: markRuntimeLoaded() is called in panelNavigationCompleted callback
- // when the navigation (for either HTML or URL) completes
+ // when the navigation completes
}
func (p *darwinPanelImpl) destroy() {
@@ -376,24 +353,6 @@ func (p *darwinPanelImpl) setURL(url string) {
C.panelLoadURL(p.webview, urlStr)
}
-func (p *darwinPanelImpl) setHTML(html string) {
- if p.webview == nil {
- return
- }
- htmlStr := C.CString(html)
- defer C.free(unsafe.Pointer(htmlStr))
- C.panelLoadHTML(p.webview, htmlStr)
-}
-
-func (p *darwinPanelImpl) execJS(js string) {
- if p.webview == nil {
- return
- }
- jsStr := C.CString(js)
- defer C.free(unsafe.Pointer(jsStr))
- C.panelExecJS(p.webview, jsStr)
-}
-
func (p *darwinPanelImpl) reload() {
if p.webview == nil {
return
diff --git a/v3/pkg/application/webview_panel_ios.go b/v3/pkg/application/webview_panel_ios.go
index c363218d6..e1684e279 100644
--- a/v3/pkg/application/webview_panel_ios.go
+++ b/v3/pkg/application/webview_panel_ios.go
@@ -38,14 +38,6 @@ func (p *iosPanelImpl) setURL(_ string) {
// Not implemented on iOS
}
-func (p *iosPanelImpl) setHTML(_ string) {
- // Not implemented on iOS
-}
-
-func (p *iosPanelImpl) execJS(_ string) {
- // Not implemented on iOS
-}
-
func (p *iosPanelImpl) reload() {
// Not implemented on iOS
}
diff --git a/v3/pkg/application/webview_panel_linux.go b/v3/pkg/application/webview_panel_linux.go
index 2ae933553..e71455fa3 100644
--- a/v3/pkg/application/webview_panel_linux.go
+++ b/v3/pkg/application/webview_panel_linux.go
@@ -43,16 +43,6 @@ static void panel_load_url(GtkWidget *webview, const char *url) {
webkit_web_view_load_uri(WEBKIT_WEB_VIEW(webview), url);
}
-// Load HTML in webview
-static void panel_load_html(GtkWidget *webview, const char *html) {
- webkit_web_view_load_html(WEBKIT_WEB_VIEW(webview), html, NULL);
-}
-
-// Execute JavaScript
-static void panel_exec_js(GtkWidget *webview, const char *js) {
- webkit_web_view_run_javascript(WEBKIT_WEB_VIEW(webview), js, NULL, NULL, NULL);
-}
-
// Reload webview
static void panel_reload(GtkWidget *webview) {
webkit_web_view_reload(WEBKIT_WEB_VIEW(webview));
@@ -219,12 +209,15 @@ func (p *linuxPanelImpl) create() {
C.gtk_widget_show_all(p.fixed)
}
- // Load initial content
- if options.HTML != "" {
- html := C.CString(options.HTML)
- defer C.free(unsafe.Pointer(html))
- C.panel_load_html(p.webview, html)
- } else if options.URL != "" {
+ // Navigate to initial URL
+ if options.URL != "" {
+ // TODO: Add support for custom headers when WebKitWebView supports it
+ if len(options.Headers) > 0 {
+ globalApplication.debug("[Panel-Linux] Custom headers specified (not yet supported)",
+ "panelID", p.panel.id,
+ "headers", options.Headers)
+ }
+
url := C.CString(options.URL)
defer C.free(unsafe.Pointer(url))
C.panel_load_url(p.webview, url)
@@ -234,9 +227,6 @@ func (p *linuxPanelImpl) create() {
if debugMode && options.OpenInspectorOnStartup {
C.panel_open_devtools(p.webview)
}
-
- // Mark runtime as loaded
- p.panel.markRuntimeLoaded()
}
func boolToInt(b bool) int {
@@ -291,24 +281,6 @@ func (p *linuxPanelImpl) setURL(url string) {
C.panel_load_url(p.webview, urlStr)
}
-func (p *linuxPanelImpl) setHTML(html string) {
- if p.webview == nil {
- return
- }
- htmlStr := C.CString(html)
- defer C.free(unsafe.Pointer(htmlStr))
- C.panel_load_html(p.webview, htmlStr)
-}
-
-func (p *linuxPanelImpl) execJS(js string) {
- if p.webview == nil {
- return
- }
- jsStr := C.CString(js)
- defer C.free(unsafe.Pointer(jsStr))
- C.panel_exec_js(p.webview, jsStr)
-}
-
func (p *linuxPanelImpl) reload() {
if p.webview == nil {
return
diff --git a/v3/pkg/application/webview_panel_options.go b/v3/pkg/application/webview_panel_options.go
index 049a30a7e..deb159193 100644
--- a/v3/pkg/application/webview_panel_options.go
+++ b/v3/pkg/application/webview_panel_options.go
@@ -1,12 +1,51 @@
package application
// WebviewPanelOptions contains options for creating a WebviewPanel.
-// Panels are absolutely positioned webview containers within a window.
+// Panels are absolutely positioned webview containers within a window,
+// similar to Electron's BrowserView or iframes in web development.
+//
+// Example - Simple panel:
+//
+// panel := window.NewPanel(application.WebviewPanelOptions{
+// Name: "browser",
+// URL: "https://example.com",
+// X: 0, Y: 50, Width: 800, Height: 600,
+// })
+//
+// Example - Panel with custom headers and anchoring:
+//
+// sidebar := window.NewPanel(application.WebviewPanelOptions{
+// Name: "api-panel",
+// URL: "https://api.example.com/dashboard",
+// Headers: map[string]string{"Authorization": "Bearer token123"},
+// X: 0, Y: 0, Width: 200, Height: 600,
+// Anchor: application.AnchorTop | application.AnchorBottom | application.AnchorLeft,
+// })
type WebviewPanelOptions struct {
// Name is a unique identifier for the panel within its parent window.
- // If empty, a name will be auto-generated.
+ // Used for retrieving panels via window.GetPanel(name).
+ // If empty, a name will be auto-generated (e.g., "panel-1").
Name string
+ // ==================== Content ====================
+
+ // URL is the URL to load in the panel.
+ // Can be:
+ // - An external URL (e.g., "https://example.com")
+ // - A local path served by the asset server (e.g., "/panel.html")
+ URL string
+
+ // Headers are custom HTTP headers to send with the initial request.
+ // These headers are only applied to the initial navigation.
+ // Example: {"Authorization": "Bearer token", "X-Custom-Header": "value"}
+ Headers map[string]string
+
+ // UserAgent overrides the default user agent string for this panel.
+ // If empty, uses the default WebView2/WebKit user agent.
+ UserAgent string
+
+ // ==================== Position & Size ====================
+
// X is the horizontal position of the panel relative to the parent window's content area.
// Uses CSS pixels (device-independent).
X int
@@ -29,58 +68,68 @@ type WebviewPanelOptions struct {
// Default: 1
ZIndex int
- // URL is the initial URL to load in the panel.
- // Can be a local path (e.g., "/panel.html") or external URL (e.g., "https://example.com").
- URL string
+ // Anchor specifies how the panel should respond to window resizing.
+ // When anchored to an edge, the panel maintains its distance from that edge.
+ //
+ // Examples:
+ // - AnchorLeft | AnchorTop: Panel stays in top-left corner
+ // - AnchorLeft | AnchorTop | AnchorBottom: Left sidebar that stretches vertically
+ // - AnchorFill: Panel fills the entire window
+ //
+ // See also: DockLeft(), DockRight(), DockTop(), DockBottom(), FillWindow()
+ Anchor AnchorType
- // HTML is the initial HTML content to display in the panel.
- // If both URL and HTML are set, HTML takes precedence.
- HTML string
-
- // JS is JavaScript to execute after the page loads.
- JS string
-
- // CSS is CSS to inject into the panel.
- CSS string
+ // ==================== Appearance ====================
// Visible controls whether the panel is initially visible.
// Default: true
Visible *bool
- // DevToolsEnabled enables the developer tools for this panel.
- // Default: follows the parent window's setting
- DevToolsEnabled *bool
-
- // Zoom is the initial zoom level of the panel.
- // Default: 1.0
- Zoom float64
-
// BackgroundColour is the background color of the panel.
+ // Only used when Transparent is false.
BackgroundColour RGBA
+ // Transparent makes the panel background transparent.
+ // Useful for overlays or panels with rounded corners.
+ // Default: false
+ Transparent bool
+
// Frameless removes the default styling/border around the panel.
// Default: false
Frameless bool
- // Transparent makes the panel background transparent.
- // Default: false
- Transparent bool
+ // Zoom is the initial zoom level of the panel.
+ // 1.0 = 100%, 1.5 = 150%, etc.
+ // Default: 1.0
+ Zoom float64
- // Anchor specifies how the panel should be anchored to the window edges.
- // When anchored, the panel maintains its distance from the specified edges
- // when the window is resized.
- Anchor AnchorType
+ // ==================== Developer Options ====================
+
+ // DevToolsEnabled enables the developer tools for this panel.
+ // Default: follows the application's debug mode setting
+ DevToolsEnabled *bool
// OpenInspectorOnStartup will open the inspector when the panel is first shown.
+ // Only works when DevToolsEnabled is true or app is in debug mode.
OpenInspectorOnStartup bool
}
// AnchorType defines how a panel is anchored within its parent window.
// Multiple anchors can be combined using bitwise OR.
+//
+// When a window is resized:
+// - Anchored edges maintain their distance from the window edge
+// - Non-anchored edges allow the panel to stretch/shrink
+//
+// Example combinations:
+// - AnchorLeft: Panel stays on left, doesn't resize
+// - AnchorLeft | AnchorRight: Panel stretches horizontally with window
+// - AnchorTop | AnchorLeft | AnchorBottom: Left sidebar that stretches vertically
type AnchorType uint8
const (
// AnchorNone - panel uses absolute positioning only (default)
+ // Panel position and size remain fixed regardless of window size changes.
AnchorNone AnchorType = 0
// AnchorTop - panel maintains distance from top edge
@@ -96,7 +145,13 @@ const (
AnchorRight
// AnchorFill - panel fills the entire window (anchored to all edges)
+ // Equivalent to: AnchorTop | AnchorBottom | AnchorLeft | AnchorRight
AnchorFill AnchorType = AnchorTop | AnchorBottom | AnchorLeft | AnchorRight
)
+// HasAnchor checks if the anchor type includes a specific anchor.
+func (a AnchorType) HasAnchor(anchor AnchorType) bool {
+ return a&anchor == anchor
+}
+
// Note: Rect is defined in screenmanager.go
diff --git a/v3/pkg/application/webview_panel_windows.go b/v3/pkg/application/webview_panel_windows.go
index 8052a75fc..fe2dd7f16 100644
--- a/v3/pkg/application/webview_panel_windows.go
+++ b/v3/pkg/application/webview_panel_windows.go
@@ -4,7 +4,8 @@ package application
import (
"fmt"
- "strings"
+ "os"
+ "path/filepath"
"github.com/wailsapp/go-webview2/pkg/edge"
"github.com/wailsapp/wails/v3/internal/assetserver"
@@ -24,14 +25,21 @@ type windowsPanelImpl struct {
func newPanelImpl(panel *WebviewPanel) webviewPanelImpl {
parentWindow := panel.parent
if parentWindow == nil || parentWindow.impl == nil {
+ globalApplication.error("[Panel-Windows] newPanelImpl: parent or parent.impl is nil",
+ "parentNil", parentWindow == nil,
+ "parentImplNil", parentWindow != nil && parentWindow.impl == nil)
return nil
}
windowsParent, ok := parentWindow.impl.(*windowsWebviewWindow)
if !ok {
+ globalApplication.error("[Panel-Windows] newPanelImpl: parent.impl is not *windowsWebviewWindow")
return nil
}
+ globalApplication.debug("[Panel-Windows] newPanelImpl: created impl",
+ "panelID", panel.id,
+ "parentHWND", windowsParent.hwnd)
return &windowsPanelImpl{
panel: panel,
parent: windowsParent,
@@ -41,6 +49,12 @@ func newPanelImpl(panel *WebviewPanel) webviewPanelImpl {
func (p *windowsPanelImpl) create() {
options := p.panel.options
+ globalApplication.debug("[Panel-Windows] create() starting",
+ "panelID", p.panel.id,
+ "panelName", p.panel.name,
+ "bounds", fmt.Sprintf("x=%d,y=%d,w=%d,h=%d", options.X, options.Y, options.Width, options.Height),
+ "parentHWND", p.parent.hwnd)
+
// Create a child window to host the WebView2
// We use WS_CHILD style to make it a child of the parent window
style := uint(w32.WS_CHILD | w32.WS_CLIPSIBLINGS)
@@ -56,6 +70,10 @@ func (p *windowsPanelImpl) create() {
Height: options.Height,
})
+ globalApplication.debug("[Panel-Windows] Physical bounds after DIP conversion",
+ "panelID", p.panel.id,
+ "physicalBounds", fmt.Sprintf("x=%d,y=%d,w=%d,h=%d", bounds.X, bounds.Y, bounds.Width, bounds.Height))
+
// Create the child window
p.hwnd = w32.CreateWindowEx(
0,
@@ -73,40 +91,63 @@ func (p *windowsPanelImpl) create() {
)
if p.hwnd == 0 {
- globalApplication.error("failed to create panel child window")
+ lastErr := w32.GetLastError()
+ globalApplication.error("[Panel-Windows] failed to create panel child window",
+ "panelID", p.panel.id,
+ "lastError", lastErr)
return
}
+ globalApplication.debug("[Panel-Windows] Child window created",
+ "panelID", p.panel.id,
+ "panelHWND", p.hwnd)
+
// Setup WebView2 (Chromium)
p.setupChromium()
}
func (p *windowsPanelImpl) setupChromium() {
+ globalApplication.debug("[Panel-Windows] setupChromium() starting", "panelID", p.panel.id)
+
p.chromium = edge.NewChromium()
if globalApplication.options.ErrorHandler != nil {
p.chromium.SetErrorCallback(globalApplication.options.ErrorHandler)
}
- // Configure chromium
- p.chromium.DataPath = globalApplication.options.Windows.WebviewUserDataPath
+ // Configure chromium with a unique data path for this panel
+ // WebView2 requires separate user data folders when multiple WebView2 instances
+ // are created in the same process with different environments
+ baseDataPath := globalApplication.options.Windows.WebviewUserDataPath
+ if baseDataPath == "" {
+ // Use default path based on app name
+ baseDataPath = filepath.Join(os.Getenv("AppData"), "wails-panels")
+ }
+ // Create a unique subdirectory for this panel
+ p.chromium.DataPath = filepath.Join(baseDataPath, fmt.Sprintf("panel-%d", p.panel.id))
p.chromium.BrowserPath = globalApplication.options.Windows.WebviewBrowserPath
+ globalApplication.debug("[Panel-Windows] Using DataPath", "panelID", p.panel.id, "dataPath", p.chromium.DataPath)
+
// Set up callbacks
p.chromium.MessageCallback = p.processMessage
p.chromium.NavigationCompletedCallback = p.navigationCompletedCallback
+ globalApplication.debug("[Panel-Windows] Calling Embed()", "panelID", p.panel.id, "hwnd", p.hwnd)
// Embed the WebView2 into our child window
p.chromium.Embed(p.hwnd)
+ globalApplication.debug("[Panel-Windows] Embed() completed, calling Resize()", "panelID", p.panel.id)
p.chromium.Resize()
// Configure settings
settings, err := p.chromium.GetSettings()
if err != nil {
- globalApplication.error("failed to get chromium settings: %v", err)
+ globalApplication.error("[Panel-Windows] failed to get chromium settings", "panelID", p.panel.id, "error", err)
return
}
+ globalApplication.debug("[Panel-Windows] Settings obtained successfully", "panelID", p.panel.id)
+
debugMode := globalApplication.isDebugMode
// Disable context menus unless in debug mode or explicitly enabled
@@ -116,12 +157,12 @@ func (p *windowsPanelImpl) setupChromium() {
}
err = settings.PutAreDefaultContextMenusEnabled(devToolsEnabled)
if err != nil {
- globalApplication.error("failed to configure context menus: %v", err)
+ globalApplication.error("[Panel-Windows] failed to configure context menus", "panelID", p.panel.id, "error", err)
}
err = settings.PutAreDevToolsEnabled(devToolsEnabled)
if err != nil {
- globalApplication.error("failed to configure devtools: %v", err)
+ globalApplication.error("[Panel-Windows] failed to configure devtools", "panelID", p.panel.id, "error", err)
}
// Set zoom if specified
@@ -141,46 +182,50 @@ func (p *windowsPanelImpl) setupChromium() {
)
}
- // Navigate to initial content
- if p.panel.options.HTML != "" {
- p.loadHTMLWithScripts()
- } else if p.panel.options.URL != "" {
+ // Navigate to initial URL
+ if p.panel.options.URL != "" {
+ globalApplication.debug("[Panel-Windows] Navigating to URL", "panelID", p.panel.id, "url", p.panel.options.URL)
startURL, err := assetserver.GetStartURL(p.panel.options.URL)
if err != nil {
- globalApplication.error("failed to get start URL: %v", err)
+ globalApplication.error("[Panel-Windows] failed to get start URL", "panelID", p.panel.id, "error", err)
return
}
+ globalApplication.debug("[Panel-Windows] Resolved start URL", "panelID", p.panel.id, "startURL", startURL)
+
+ // TODO: Add support for custom headers when WebView2 supports it
+ // For now, headers are logged but not applied
+ if len(p.panel.options.Headers) > 0 {
+ globalApplication.debug("[Panel-Windows] Custom headers specified (not yet supported)",
+ "panelID", p.panel.id,
+ "headers", p.panel.options.Headers)
+ }
+
p.chromium.Navigate(startURL)
+ } else {
+ globalApplication.debug("[Panel-Windows] No URL specified", "panelID", p.panel.id)
}
+ globalApplication.debug("[Panel-Windows] setupChromium() completed", "panelID", p.panel.id)
+
+ // Force show the panel window and bring it to the top
+ w32.ShowWindow(p.hwnd, w32.SW_SHOW)
+ w32.SetWindowPos(
+ p.hwnd,
+ w32.HWND_TOP,
+ 0, 0, 0, 0,
+ w32.SWP_NOMOVE|w32.SWP_NOSIZE|w32.SWP_NOACTIVATE|w32.SWP_SHOWWINDOW,
+ )
+ globalApplication.debug("[Panel-Windows] Panel window shown and brought to top",
+ "panelID", p.panel.id,
+ "hwnd", p.hwnd,
+ "isVisible", w32.IsWindowVisible(p.hwnd))
+
// Open inspector if requested
if debugMode && p.panel.options.OpenInspectorOnStartup {
p.chromium.OpenDevToolsWindow()
}
}
-func (p *windowsPanelImpl) loadHTMLWithScripts() {
- var script string
- if p.panel.options.JS != "" {
- script = p.panel.options.JS
- }
- if p.panel.options.CSS != "" {
- // Escape CSS for safe injection into JavaScript string
- escapedCSS := strings.ReplaceAll(p.panel.options.CSS, `\`, `\\`)
- escapedCSS = strings.ReplaceAll(escapedCSS, `"`, `\"`)
- escapedCSS = strings.ReplaceAll(escapedCSS, "\n", `\n`)
- escapedCSS = strings.ReplaceAll(escapedCSS, "\r", `\r`)
- script += fmt.Sprintf(
- "; addEventListener(\"DOMContentLoaded\", (event) => { document.head.appendChild(document.createElement('style')).innerHTML=\"%s\"; });",
- escapedCSS,
- )
- }
- if script != "" {
- p.chromium.Init(script)
- }
- p.chromium.NavigateToString(p.panel.options.HTML)
-}
-
func (p *windowsPanelImpl) processMessage(message string, _ *edge.ICoreWebView2, _ *edge.ICoreWebView2WebMessageReceivedEventArgs) {
// For now, just log panel messages
// In future, we could route these to the parent window or handle panel-specific messages
@@ -191,24 +236,7 @@ func (p *windowsPanelImpl) navigationCompletedCallback(_ *edge.ICoreWebView2, _
p.navigationCompleted = true
// Execute any pending JS
- if p.panel.options.JS != "" && p.panel.options.HTML == "" {
- p.execJS(p.panel.options.JS)
- }
- if p.panel.options.CSS != "" && p.panel.options.HTML == "" {
- // Escape CSS for safe injection into JavaScript string
- escapedCSS := strings.ReplaceAll(p.panel.options.CSS, `\`, `\\`)
- escapedCSS = strings.ReplaceAll(escapedCSS, `'`, `\'`)
- escapedCSS = strings.ReplaceAll(escapedCSS, "\n", `\n`)
- escapedCSS = strings.ReplaceAll(escapedCSS, "\r", `\r`)
- js := fmt.Sprintf(
- "(function() { var style = document.createElement('style'); style.appendChild(document.createTextNode('%s')); document.head.appendChild(style); })();",
- escapedCSS,
- )
- p.execJS(js)
- }
-
- // Mark runtime as loaded
- p.panel.markRuntimeLoaded()
+ // Navigation completed - no additional action needed
}
func (p *windowsPanelImpl) destroy() {
@@ -314,26 +342,15 @@ func (p *windowsPanelImpl) setURL(url string) {
p.chromium.Navigate(startURL)
}
-func (p *windowsPanelImpl) setHTML(html string) {
- if p.chromium == nil {
- return
- }
- p.chromium.NavigateToString(html)
-}
-
-func (p *windowsPanelImpl) execJS(js string) {
+func (p *windowsPanelImpl) reload() {
if p.chromium == nil {
return
}
globalApplication.dispatchOnMainThread(func() {
- p.chromium.Eval(js)
+ p.chromium.Eval("window.location.reload();")
})
}
-func (p *windowsPanelImpl) reload() {
- p.execJS("window.location.reload();")
-}
-
func (p *windowsPanelImpl) forceReload() {
// WebView2 doesn't have a cache-bypass reload, so just reload normally
p.reload()
diff --git a/v3/pkg/application/webview_window.go b/v3/pkg/application/webview_window.go
index c07acdfc8..8e26bbd23 100644
--- a/v3/pkg/application/webview_window.go
+++ b/v3/pkg/application/webview_window.go
@@ -175,8 +175,9 @@ type WebviewWindow struct {
unconditionallyClose uint32
// Embedded panels management
- panels map[uint]*WebviewPanel
- panelsLock sync.RWMutex
+ panels map[uint]*WebviewPanel
+ panelsLock sync.RWMutex
+ panelResizeHandlerRegistered bool // Tracks if resize handler is registered
}
func (w *WebviewWindow) SetMenu(menu *Menu) {
@@ -1556,6 +1557,12 @@ func (w *WebviewWindow) SnapAssist() {
// URL: "https://example.com",
// })
func (w *WebviewWindow) NewPanel(options WebviewPanelOptions) *WebviewPanel {
+ globalApplication.debug("[Window] NewPanel() called",
+ "windowID", w.id,
+ "panelName", options.Name,
+ "windowImplNil", w.impl == nil,
+ "windowDestroyed", w.isDestroyed())
+
panel := NewPanel(options)
panel.parent = w
@@ -1563,9 +1570,30 @@ func (w *WebviewWindow) NewPanel(options WebviewPanelOptions) *WebviewPanel {
w.panels[panel.id] = panel
w.panelsLock.Unlock()
+ globalApplication.debug("[Window] Panel registered",
+ "windowID", w.id,
+ "panelID", panel.id,
+ "panelName", panel.name)
+
// If window is already running, start the panel immediately
if w.impl != nil && !w.isDestroyed() {
+ globalApplication.debug("[Window] Window impl exists, starting panel immediately",
+ "windowID", w.id,
+ "panelID", panel.id)
+ // Initialize anchor with current window size
+ panel.initializeAnchor()
InvokeSync(panel.run)
+
+ // Ensure resize handler is registered if this panel has anchoring
+ if options.Anchor != AnchorNone {
+ w.ensurePanelResizeHandler()
+ }
+ } else {
+ globalApplication.debug("[Window] Window impl not ready, panel will be started later",
+ "windowID", w.id,
+ "panelID", panel.id,
+ "implNil", w.impl == nil,
+ "destroyed", w.isDestroyed())
}
return panel
@@ -1635,6 +1663,8 @@ func (w *WebviewWindow) removePanel(id uint) {
// runPanels starts all panels that haven't been started yet.
// This is called after the window's impl is created.
func (w *WebviewWindow) runPanels() {
+ globalApplication.debug("[Window] runPanels() called", "windowID", w.id)
+
// Collect panels under lock, then run them outside the lock
w.panelsLock.RLock()
panels := make([]*WebviewPanel, 0, len(w.panels))
@@ -1645,9 +1675,78 @@ func (w *WebviewWindow) runPanels() {
}
w.panelsLock.RUnlock()
+ globalApplication.debug("[Window] runPanels() found panels to start",
+ "windowID", w.id,
+ "panelCount", len(panels))
+
for _, panel := range panels {
+ globalApplication.debug("[Window] runPanels() starting panel",
+ "windowID", w.id,
+ "panelID", panel.id,
+ "panelName", panel.name)
+ panel.initializeAnchor()
panel.run()
}
+
+ // Register resize event handler for panel responsive layout if needed
+ w.ensurePanelResizeHandler()
+
+ globalApplication.debug("[Window] runPanels() completed", "windowID", w.id)
+}
+
+// ensurePanelResizeHandler registers the resize event handler if there are anchored panels
+// and the handler hasn't been registered yet.
+func (w *WebviewWindow) ensurePanelResizeHandler() {
+ w.panelsLock.Lock()
+ if w.panelResizeHandlerRegistered {
+ w.panelsLock.Unlock()
+ return
+ }
+
+ // Check if we have any anchored panels
+ hasAnchoredPanels := false
+ for _, panel := range w.panels {
+ if panel.options.Anchor != AnchorNone {
+ hasAnchoredPanels = true
+ break
+ }
+ }
+
+ if !hasAnchoredPanels {
+ w.panelsLock.Unlock()
+ return
+ }
+
+ w.panelResizeHandlerRegistered = true
+ w.panelsLock.Unlock()
+
+ w.OnWindowEvent(events.Common.WindowDidResize, func(event *WindowEvent) {
+ w.handlePanelResize()
+ })
+}
+
+// handlePanelResize updates all anchored panels when the window is resized.
+// This should be called when the window size changes.
+func (w *WebviewWindow) handlePanelResize() {
+ w.panelsLock.RLock()
+ panels := make([]*WebviewPanel, 0, len(w.panels))
+ for _, panel := range w.panels {
+ if panel.options.Anchor != AnchorNone {
+ panels = append(panels, panel)
+ }
+ }
+ w.panelsLock.RUnlock()
+
+ if len(panels) == 0 {
+ return
+ }
+
+ // Get new window size
+ newWidth, newHeight := w.Size()
+
+ for _, panel := range panels {
+ panel.handleWindowResize(newWidth, newHeight)
+ }
}
// destroyAllPanels destroys all panels in this window.
From 4ac1d93b70a1c92fc3273211784168fa664675f9 Mon Sep 17 00:00:00 2001
From: WCY-dt <834421194@qq.com>
Date: Wed, 28 Jan 2026 21:00:15 +0800
Subject: [PATCH 5/7] docs: update README.md to enhance WebviewPanel example
and clarify features
---
v3/examples/webview-panel/README.md | 138 ++++++++++++++++++----------
1 file changed, 92 insertions(+), 46 deletions(-)
diff --git a/v3/examples/webview-panel/README.md b/v3/examples/webview-panel/README.md
index 778ef3d61..fbbd3d147 100644
--- a/v3/examples/webview-panel/README.md
+++ b/v3/examples/webview-panel/README.md
@@ -2,22 +2,27 @@
This example demonstrates the **WebviewPanel** feature - embedding multiple independent webview panels within a single window. This is similar to Electron's BrowserView/WebContentsView and addresses [GitHub issue #1997](https://github.com/wailsapp/wails/issues/1997).
-## Features
+## Features Demonstrated
-- Create multiple webview panels within a single window
-- Panels are absolutely positioned with X, Y, Width, Height
-- Each panel can load different URLs or HTML content
-- Independent JavaScript execution in each panel
-- Z-index support for panel stacking
-- Layout helper methods for common patterns (DockLeft, DockRight, etc.)
+- **URL Loading**: Load external websites (like https://wails.io) in an embedded panel
+- **Responsive Layout**: Panel automatically resizes with the window using anchor settings
+- **Dynamic Switching**: Switch panel content between different URLs at runtime
+- **Custom UI Integration**: Panel embedded within a custom HTML interface
## Running
```bash
cd v3/examples/webview-panel
-go run main.go
+go run .
```
+## What This Example Shows
+
+1. **Embedded Webview**: The main window displays a custom UI with a header and navigation buttons
+2. **Panel Inside Window**: An embedded webview panel shows https://wails.io inside the window
+3. **URL Switching**: Click the "Wails.io" or "Google.com" buttons to switch the panel content
+4. **Responsive Behavior**: Resize the window to see the panel automatically adjust its size
+
## Use Cases
WebviewPanel is ideal for:
@@ -34,33 +39,90 @@ WebviewPanel is ideal for:
### Creating Panels
```go
-// Create a panel with explicit positioning
+// Create a panel with URL and positioning
+panel := window.NewPanel(application.WebviewPanelOptions{
+ Name: "content",
+ URL: "https://example.com",
+ X: 20,
+ Y: 60,
+ Width: 800,
+ Height: 500,
+})
+
+// Panel with anchoring (responsive to window resize)
panel := window.NewPanel(application.WebviewPanelOptions{
Name: "sidebar",
+ URL: "/sidebar.html",
X: 0,
- Y: 50,
+ Y: 0,
Width: 200,
Height: 600,
- URL: "https://example.com",
- // Or use HTML:
- // HTML: "Hello Panel!
",
+ Anchor: application.AnchorTop | application.AnchorBottom | application.AnchorLeft,
+})
+
+// Panel that fills the entire window
+panel := window.NewPanel(application.WebviewPanelOptions{
+ Name: "fullscreen",
+ URL: "https://wails.io",
+ X: 0,
+ Y: 0,
+ Width: 800,
+ Height: 600,
+ Anchor: application.AnchorFill,
})
```
-### Layout Helpers
+### Anchor Types
+
+Anchors control how panels respond to window resizing:
+
+| Anchor | Behavior |
+|--------|----------|
+| `AnchorNone` | Fixed position and size |
+| `AnchorTop` | Maintains distance from top edge |
+| `AnchorBottom` | Maintains distance from bottom edge |
+| `AnchorLeft` | Maintains distance from left edge |
+| `AnchorRight` | Maintains distance from right edge |
+| `AnchorFill` | Anchored to all edges (fills window with margins) |
+
+Combine anchors with `|` for complex layouts:
+```go
+// Left sidebar that stretches vertically
+Anchor: application.AnchorTop | application.AnchorBottom | application.AnchorLeft
+```
+
+### Panel Options
```go
-// Dock to edges
-sidebar := window.NewPanel(opts).DockLeft(200) // Left sidebar
-inspector := window.NewPanel(opts).DockRight(300) // Right panel
-toolbar := window.NewPanel(opts).DockTop(50) // Top toolbar
-statusBar := window.NewPanel(opts).DockBottom(30) // Bottom status
-
-// Fill remaining space
-content := window.NewPanel(opts).FillBeside(sidebar, "right")
-
-// Fill entire window
-fullPanel := window.NewPanel(opts).FillWindow()
+application.WebviewPanelOptions{
+ // Identity
+ Name: "panel-name", // Unique identifier
+
+ // Content
+ URL: "https://example.com", // URL to load
+ Headers: map[string]string{ // Custom HTTP headers (optional)
+ "Authorization": "Bearer token",
+ },
+ UserAgent: "Custom UA", // Custom user agent (optional)
+
+ // Position & Size
+ X: 100, // X position (CSS pixels)
+ Y: 50, // Y position (CSS pixels)
+ Width: 800, // Width (CSS pixels)
+ Height: 600, // Height (CSS pixels)
+ ZIndex: 1, // Stacking order
+ Anchor: application.AnchorFill, // Resize behavior
+
+ // Appearance
+ Visible: boolPtr(true), // Initially visible
+ BackgroundColour: application.NewRGB(255, 255, 255),
+ Transparent: false, // Transparent background
+ Zoom: 1.0, // Zoom level (1.0 = 100%)
+
+ // Developer
+ DevToolsEnabled: boolPtr(true),
+ OpenInspectorOnStartup: false,
+}
```
### Panel Manipulation
@@ -68,14 +130,12 @@ fullPanel := window.NewPanel(opts).FillWindow()
```go
// Position and size
panel.SetBounds(application.Rect{X: 100, Y: 50, Width: 400, Height: 300})
-panel.SetPosition(200, 100)
-panel.SetSize(500, 400)
+bounds := panel.Bounds()
// Content
panel.SetURL("https://wails.io")
-panel.SetHTML("Dynamic content
")
-panel.ExecJS("console.log('Hello from panel!')")
panel.Reload()
+panel.ForceReload() // Bypass cache
// Visibility
panel.Show()
@@ -87,9 +147,11 @@ panel.SetZIndex(10)
// Focus
panel.Focus()
+focused := panel.IsFocused()
// Zoom
panel.SetZoom(1.5)
+zoom := panel.Zoom()
// Developer tools
panel.OpenDevTools()
@@ -114,23 +176,6 @@ panels := window.GetPanels()
window.RemovePanel("sidebar")
```
-## TypeScript/Frontend API
-
-```typescript
-import { Panel } from '@wailsio/runtime';
-
-// Get a reference to a panel
-const panel = Panel.Get("content");
-
-// Manipulate from frontend
-await panel.SetBounds({ x: 100, y: 50, width: 500, height: 400 });
-await panel.SetURL("https://wails.io");
-await panel.ExecJS("document.body.style.background = 'red'");
-await panel.Show();
-await panel.Hide();
-await panel.Focus();
-```
-
## Key Differences from Windows
| Feature | WebviewWindow | WebviewPanel |
@@ -141,3 +186,4 @@ await panel.Focus();
| Can show external URLs | ✅ | ✅ |
| Multiple per app | ✅ | ✅ (multiple per window) |
| Position relative to | Screen | Parent window |
+| Responsive anchoring | ❌ | ✅ |
From b9856cb561d65f7f82f128465bacae8ccab2f9ab Mon Sep 17 00:00:00 2001
From: WCY-dt <834421194@qq.com>
Date: Wed, 28 Jan 2026 21:31:47 +0800
Subject: [PATCH 6/7] docs: update README.md to format URLs correctly and
clarify example features
fix: prevent multiple panel creations on repeated window show events
feat: normalize local URLs via asset server in WebviewPanel
refactor: improve SetBounds and add updateAnchorBaseline for better resizing behavior
fix: ensure navigation delegate is set for WKWebView in macOS implementation
---
v3/examples/webview-panel/README.md | 6 +--
v3/examples/webview-panel/main.go | 9 +++-
v3/pkg/application/webview_panel.go | 48 ++++++++++++++++++++--
v3/pkg/application/webview_panel_darwin.go | 3 ++
4 files changed, 58 insertions(+), 8 deletions(-)
diff --git a/v3/examples/webview-panel/README.md b/v3/examples/webview-panel/README.md
index fbbd3d147..c0caf7481 100644
--- a/v3/examples/webview-panel/README.md
+++ b/v3/examples/webview-panel/README.md
@@ -4,7 +4,7 @@ This example demonstrates the **WebviewPanel** feature - embedding multiple inde
## Features Demonstrated
-- **URL Loading**: Load external websites (like https://wails.io) in an embedded panel
+- **URL Loading**: Load external websites (like ) in an embedded panel
- **Responsive Layout**: Panel automatically resizes with the window using anchor settings
- **Dynamic Switching**: Switch panel content between different URLs at runtime
- **Custom UI Integration**: Panel embedded within a custom HTML interface
@@ -19,7 +19,7 @@ go run .
## What This Example Shows
1. **Embedded Webview**: The main window displays a custom UI with a header and navigation buttons
-2. **Panel Inside Window**: An embedded webview panel shows https://wails.io inside the window
+2. **Panel Inside Window**: An embedded webview panel shows inside the window
3. **URL Switching**: Click the "Wails.io" or "Google.com" buttons to switch the panel content
4. **Responsive Behavior**: Resize the window to see the panel automatically adjust its size
@@ -151,7 +151,7 @@ focused := panel.IsFocused()
// Zoom
panel.SetZoom(1.5)
-zoom := panel.Zoom()
+zoom := panel.GetZoom()
// Developer tools
panel.OpenDevTools()
diff --git a/v3/examples/webview-panel/main.go b/v3/examples/webview-panel/main.go
index 3cf2f756e..09ee03dc7 100644
--- a/v3/examples/webview-panel/main.go
+++ b/v3/examples/webview-panel/main.go
@@ -51,8 +51,15 @@ func main() {
URL: "/index.html",
})
- // Create the embedded panel after the window is shown
+ // Create the embedded panel after the window is shown (only once)
+ var panelCreated bool
window.OnWindowEvent(events.Common.WindowShow, func(*application.WindowEvent) {
+ // Guard against creating multiple panels on repeated WindowShow events
+ if panelCreated {
+ return
+ }
+ panelCreated = true
+
// Based on actual HTML measurements:
// - Header: 41px height (with 15px/25px padding already included)
// - Content area: padding 20px
diff --git a/v3/pkg/application/webview_panel.go b/v3/pkg/application/webview_panel.go
index addee0efa..848fadd61 100644
--- a/v3/pkg/application/webview_panel.go
+++ b/v3/pkg/application/webview_panel.go
@@ -4,6 +4,8 @@ import (
"fmt"
"sync"
"sync/atomic"
+
+ "github.com/wailsapp/wails/v3/internal/assetserver"
)
// webviewPanelImpl is the platform-specific interface for WebviewPanel
@@ -94,6 +96,12 @@ func NewPanel(options WebviewPanelOptions) *WebviewPanel {
options.Visible = &visible
}
+ // Normalize URL via asset server for local paths
+ if options.URL != "" {
+ normalizedURL, _ := assetserver.GetStartURL(options.URL)
+ options.URL = normalizedURL
+ }
+
// Store original bounds for anchor calculations
originalBounds := Rect{
X: options.X,
@@ -125,13 +133,17 @@ func (p *WebviewPanel) Parent() *WebviewWindow {
return p.parent
}
-// SetBounds sets the position and size of the panel within its parent window
+// SetBounds sets the position and size of the panel within its parent window.
+// This also updates the anchor baseline so future window resizes calculate from the new position.
func (p *WebviewPanel) SetBounds(bounds Rect) *WebviewPanel {
p.options.X = bounds.X
p.options.Y = bounds.Y
p.options.Width = bounds.Width
p.options.Height = bounds.Height
+ // Update anchor baseline so future resizes calculate from the new position
+ p.updateAnchorBaseline(bounds)
+
if p.impl != nil && !p.isDestroyed() {
InvokeSync(func() {
p.impl.setBounds(bounds)
@@ -140,6 +152,15 @@ func (p *WebviewPanel) SetBounds(bounds Rect) *WebviewPanel {
return p
}
+// updateAnchorBaseline updates the original bounds and window size used for anchor calculations.
+// Called when the user manually changes panel bounds.
+func (p *WebviewPanel) updateAnchorBaseline(bounds Rect) {
+ p.originalBounds = bounds
+ if p.parent != nil {
+ p.originalWindowWidth, p.originalWindowHeight = p.parent.Size()
+ }
+}
+
// Bounds returns the current bounds of the panel
func (p *WebviewPanel) Bounds() Rect {
if p.impl != nil && !p.isDestroyed() {
@@ -198,11 +219,14 @@ func (p *WebviewPanel) ZIndex() int {
}
// SetURL navigates the panel to the specified URL
+// Local paths (e.g., "/panel.html") are normalized via the asset server.
func (p *WebviewPanel) SetURL(url string) *WebviewPanel {
- p.options.URL = url
+ // Normalize URL via asset server for local paths
+ normalizedURL, _ := assetserver.GetStartURL(url)
+ p.options.URL = normalizedURL
if p.impl != nil && !p.isDestroyed() {
InvokeSync(func() {
- p.impl.setURL(url)
+ p.impl.setURL(normalizedURL)
})
}
return p
@@ -485,7 +509,23 @@ func (p *WebviewPanel) handleWindowResize(newWindowWidth, newWindowHeight int) {
}
newBounds := p.calculateAnchoredBounds(newWindowWidth, newWindowHeight)
- p.SetBounds(newBounds)
+ // Use internal setBounds to avoid updating anchor baseline during resize
+ p.setBoundsInternal(newBounds)
+}
+
+// setBoundsInternal sets bounds without updating anchor baseline.
+// Used internally during window resize handling.
+func (p *WebviewPanel) setBoundsInternal(bounds Rect) {
+ p.options.X = bounds.X
+ p.options.Y = bounds.Y
+ p.options.Width = bounds.Width
+ p.options.Height = bounds.Height
+
+ if p.impl != nil && !p.isDestroyed() {
+ InvokeSync(func() {
+ p.impl.setBounds(bounds)
+ })
+ }
}
// calculateAnchoredBounds computes the new bounds based on anchor settings.
diff --git a/v3/pkg/application/webview_panel_darwin.go b/v3/pkg/application/webview_panel_darwin.go
index bb120ebff..1a8fbb77a 100644
--- a/v3/pkg/application/webview_panel_darwin.go
+++ b/v3/pkg/application/webview_panel_darwin.go
@@ -64,6 +64,9 @@ void* panelNew(unsigned int panelId, unsigned int windowId, void* parentWindow,
WKWebView* webView = [[WKWebView alloc] initWithFrame:frame configuration:config];
delegate.webView = webView;
+ // Set navigation delegate so didFinishNavigation callback fires
+ [webView setNavigationDelegate:delegate];
+
// Configure webview
[webView setAutoresizingMask:NSViewNotSizable];
From 8a3934097bd8150367379d21bbc856e3417710dd Mon Sep 17 00:00:00 2001
From: WCY-dt <834421194@qq.com>
Date: Wed, 28 Jan 2026 21:44:12 +0800
Subject: [PATCH 7/7] docs: update README.md to clarify key differences between
WebviewWindow and WebviewPanel refactor: improve URL normalization in
NewPanel and SetURL methods
---
v3/examples/webview-panel/README.md | 2 +-
v3/pkg/application/webview_panel.go | 12 ++++++++----
2 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/v3/examples/webview-panel/README.md b/v3/examples/webview-panel/README.md
index c0caf7481..15e571b6d 100644
--- a/v3/examples/webview-panel/README.md
+++ b/v3/examples/webview-panel/README.md
@@ -176,7 +176,7 @@ panels := window.GetPanels()
window.RemovePanel("sidebar")
```
-## Key Differences from Windows
+## Key Differences: WebviewWindow vs WebviewPanel
| Feature | WebviewWindow | WebviewPanel |
|---------|---------------|--------------|
diff --git a/v3/pkg/application/webview_panel.go b/v3/pkg/application/webview_panel.go
index 848fadd61..1659f5d87 100644
--- a/v3/pkg/application/webview_panel.go
+++ b/v3/pkg/application/webview_panel.go
@@ -70,7 +70,7 @@ type WebviewPanel struct {
}
// NewPanel creates a new WebviewPanel with the given options.
-// The panel must be associated with a parent window via window.AddPanel().
+// Typically called via window.NewPanel() to associate the panel with a parent window.
func NewPanel(options WebviewPanelOptions) *WebviewPanel {
id := getNextPanelID()
@@ -98,8 +98,9 @@ func NewPanel(options WebviewPanelOptions) *WebviewPanel {
// Normalize URL via asset server for local paths
if options.URL != "" {
- normalizedURL, _ := assetserver.GetStartURL(options.URL)
- options.URL = normalizedURL
+ if normalizedURL, err := assetserver.GetStartURL(options.URL); err == nil && normalizedURL != "" {
+ options.URL = normalizedURL
+ }
}
// Store original bounds for anchor calculations
@@ -222,7 +223,10 @@ func (p *WebviewPanel) ZIndex() int {
// Local paths (e.g., "/panel.html") are normalized via the asset server.
func (p *WebviewPanel) SetURL(url string) *WebviewPanel {
// Normalize URL via asset server for local paths
- normalizedURL, _ := assetserver.GetStartURL(url)
+ normalizedURL := url
+ if normalized, err := assetserver.GetStartURL(url); err == nil && normalized != "" {
+ normalizedURL = normalized
+ }
p.options.URL = normalizedURL
if p.impl != nil && !p.isDestroyed() {
InvokeSync(func() {