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 + + + +
+

🖥️ WebviewPanel Demo

+ Multiple independent webviews in one window + Panels render below this header area +
+ +`, + }) + + // ===================================================================== + // 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

+ +
+
+

Favorites

+ +
+ +`, + 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: ` + + +
+
+

🪟 WebviewPanel Demo

+
Embedded webviews with responsive layout
+
+ +
+ +
+
+
+ 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 - - - -
-

🖥️ WebviewPanel Demo

- Multiple independent webviews in one window - Panels render below this header area -
- -`, + 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() {