diff --git a/v3/examples/webview-panel/README.md b/v3/examples/webview-panel/README.md new file mode 100644 index 000000000..15e571b6d --- /dev/null +++ b/v3/examples/webview-panel/README.md @@ -0,0 +1,189 @@ +# 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 Demonstrated + +- **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 + +## Running + +```bash +cd v3/examples/webview-panel +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 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: + +- **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 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: 0, + Width: 200, + Height: 600, + 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, +}) +``` + +### 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 +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 + +```go +// Position and size +panel.SetBounds(application.Rect{X: 100, Y: 50, Width: 400, Height: 300}) +bounds := panel.Bounds() + +// Content +panel.SetURL("https://wails.io") +panel.Reload() +panel.ForceReload() // Bypass cache + +// Visibility +panel.Show() +panel.Hide() +visible := panel.IsVisible() + +// Stacking order +panel.SetZIndex(10) + +// Focus +panel.Focus() +focused := panel.IsFocused() + +// Zoom +panel.SetZoom(1.5) +zoom := panel.GetZoom() + +// 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") +``` + +## Key Differences: WebviewWindow vs WebviewPanel + +| 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 | +| Responsive anchoring | ❌ | ✅ | diff --git a/v3/examples/webview-panel/assets/index.html b/v3/examples/webview-panel/assets/index.html new file mode 100644 index 000000000..aa7cf8d73 --- /dev/null +++ b/v3/examples/webview-panel/assets/index.html @@ -0,0 +1,225 @@ + + + + + + WebviewPanel Demo + + + + +
+
+

🪟 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 new file mode 100644 index 000000000..09ee03dc7 --- /dev/null +++ b/v3/examples/webview-panel/main.go @@ -0,0 +1,105 @@ +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 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 our custom UI + window := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "WebviewPanel Demo - Embedded Webviews", + Width: 1200, + Height: 800, + BackgroundType: application.BackgroundTypeSolid, + BackgroundColour: application.NewRGB(26, 26, 46), + URL: "/index.html", + }) + + // 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 + // - 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), + }) + }) + + // 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 1cdac37c4..2e925921e 100644 --- a/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts +++ b/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts @@ -29,6 +29,8 @@ import * as IOS from "./ios.js"; import Window, { handleDragEnter, handleDragLeave, handleDragOver } from "./window.js"; import * as WML from "./wml.js"; +export { Panel } from "./panel.js"; + export { Application, Browser, 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 a8b845470..bb95f83e5 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..4cd0d6c14 --- /dev/null +++ b/v3/pkg/application/messageprocessor_panel.go @@ -0,0 +1,207 @@ +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 + 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{ + PanelSetBounds: "SetBounds", + PanelGetBounds: "GetBounds", + PanelSetZIndex: "SetZIndex", + PanelSetURL: "SetURL", + PanelReload: "Reload", + PanelForceReload: "ForceReload", + PanelShow: "Show", + PanelHide: "Hide", + PanelIsVisible: "IsVisible", + PanelSetZoom: "SetZoom", + PanelGetZoom: "GetZoom", + PanelFocus: "Focus", + PanelIsFocused: "IsFocused", + PanelOpenDevTools: "OpenDevTools", + PanelDestroy: "Destroy", + 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, + 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, +) (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 by name or ID + panel := ww.GetPanel(*panelName) + if panel == nil { + 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) + } + + // 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 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 new file mode 100644 index 000000000..1659f5d87 --- /dev/null +++ b/v3/pkg/application/webview_panel.go @@ -0,0 +1,603 @@ +package application + +import ( + "fmt" + "sync" + "sync/atomic" + + "github.com/wailsapp/wails/v3/internal/assetserver" +) + +// 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) + reload() + forceReload() + + // Visibility + show() + hide() + isVisible() bool + + // Zoom + setZoom(zoom float64) + getZoom() float64 + + // DevTools + openDevTools() + + // Focus + focus() + isFocused() bool +} + +var panelID uint32 + +func getNextPanelID() uint { + 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 + + // 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. +// Typically called via window.NewPanel() to associate the panel with a parent window. +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 + } + + // Normalize URL via asset server for local paths + if options.URL != "" { + if normalizedURL, err := assetserver.GetStartURL(options.URL); err == nil && normalizedURL != "" { + options.URL = normalizedURL + } + } + + // 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, + originalBounds: originalBounds, + } +} + +// 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. +// 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) + }) + } + 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() { + 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 +// 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 := url + if normalized, err := assetserver.GetStartURL(url); err == nil && normalized != "" { + normalizedURL = normalized + } + p.options.URL = normalizedURL + if p.impl != nil && !p.isDestroyed() { + InvokeSync(func() { + p.impl.setURL(normalizedURL) + }) + } + return p +} + +// URL returns the current URL of the panel +func (p *WebviewPanel) URL() string { + return p.options.URL +} + +// 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() { + 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() + + 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) +} + +// ========================================================================= +// 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) +} + +// ========================================================================= +// 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) + // 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. +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 new file mode 100644 index 000000000..47c4658e2 --- /dev/null +++ b/v3/pkg/application/webview_panel_android.go @@ -0,0 +1,79 @@ +//go:build android + +package application + +// Android stub implementation for WebviewPanel +// Panels are not yet supported on Android. +// All methods are no-ops until Android platform support is implemented. + +type androidPanelImpl struct { + panel *WebviewPanel +} + +func newPanelImpl(panel *WebviewPanel) webviewPanelImpl { + return &androidPanelImpl{panel: panel} +} + +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) 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 new file mode 100644 index 000000000..1a8fbb77a --- /dev/null +++ b/v3/pkg/application/webview_panel_darwin.go @@ -0,0 +1,427 @@ +//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 windowId, unsigned int panelId); + panelNavigationCompleted(self.windowId, 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 autorelease]; + 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; + + // Set navigation delegate so didFinishNavigation callback fires + [webView setNavigationDelegate:delegate]; + + // 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) +// Note: This is a binary implementation - panels are either on top (zIndex > 0) +// or at the bottom (zIndex <= 0). Granular z-index ordering would require tracking +// all panels and repositioning them relative to each other using NSWindowOrderingMode. +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]; +} + +// 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(windowID C.uint, panelID C.uint) { + // Navigation completed callback - could be used for future functionality + globalApplication.debug("panelNavigationCompleted", "windowID", uint(windowID), "panelID", uint(panelID)) +} + +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)) + } + + // 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 completes +} + +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) 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..e1684e279 --- /dev/null +++ b/v3/pkg/application/webview_panel_ios.go @@ -0,0 +1,79 @@ +//go:build ios + +package application + +// iOS stub implementation for WebviewPanel +// Panels are not yet supported on iOS. +// All methods are no-ops until iOS platform support is implemented. + +type iosPanelImpl struct { + panel *WebviewPanel +} + +func newPanelImpl(panel *WebviewPanel) webviewPanelImpl { + return &iosPanelImpl{panel: panel} +} + +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) 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 new file mode 100644 index 000000000..e71455fa3 --- /dev/null +++ b/v3/pkg/application/webview_panel_linux.go @@ -0,0 +1,352 @@ +//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); +} + +// 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) + } + + // 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) + } + + // Open inspector if requested + if debugMode && options.OpenInspectorOnStartup { + C.panel_open_devtools(p.webview) + } +} + +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(_ 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) 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..deb159193 --- /dev/null +++ b/v3/pkg/application/webview_panel_options.go @@ -0,0 +1,157 @@ +package application + +// WebviewPanelOptions contains options for creating a WebviewPanel. +// 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. + // 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 + + // 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 + + // 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 + + // ==================== Appearance ==================== + + // Visible controls whether the panel is initially visible. + // Default: true + Visible *bool + + // 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 + + // Zoom is the initial zoom level of the panel. + // 1.0 = 100%, 1.5 = 150%, etc. + // Default: 1.0 + Zoom float64 + + // ==================== 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 + 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) + // 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 new file mode 100644 index 000000000..fe2dd7f16 --- /dev/null +++ b/v3/pkg/application/webview_panel_windows.go @@ -0,0 +1,425 @@ +//go:build windows + +package application + +import ( + "fmt" + "os" + "path/filepath" + + "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 { + 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, + } +} + +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) + 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, + }) + + 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, + 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 { + 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 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("[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 + devToolsEnabled := debugMode + if p.panel.options.DevToolsEnabled != nil { + devToolsEnabled = *p.panel.options.DevToolsEnabled + } + err = settings.PutAreDefaultContextMenusEnabled(devToolsEnabled) + if err != nil { + globalApplication.error("[Panel-Windows] failed to configure context menus", "panelID", p.panel.id, "error", err) + } + + err = settings.PutAreDevToolsEnabled(devToolsEnabled) + if err != nil { + globalApplication.error("[Panel-Windows] failed to configure devtools", "panelID", p.panel.id, "error", 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 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("[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) 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(_ *edge.ICoreWebView2, _ *edge.ICoreWebView2NavigationCompletedEventArgs) { + p.navigationCompleted = true + + // Execute any pending JS + // Navigation completed - no additional action needed +} + +func (p *windowsPanelImpl) destroy() { + if p.chromium != nil { + p.chromium.ShuttingDown() + } + 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 + 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. + // Note: This is a binary implementation - panels are either on top (zIndex > 0) + // or at the bottom (zIndex <= 0). Granular z-index ordering is not supported + // on Windows because child windows share a z-order space and precise positioning + // would require tracking all panels and re-ordering them relative to each other. + 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) reload() { + if p.chromium == nil { + return + } + globalApplication.dispatchOnMainThread(func() { + p.chromium.Eval("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 91909e83e..37fd8ea97 100644 --- a/v3/pkg/application/webview_window.go +++ b/v3/pkg/application/webview_window.go @@ -174,6 +174,11 @@ type WebviewWindow struct { // unconditionallyClose marks the window to be unconditionally closed (atomic) unconditionallyClose uint32 + + // Embedded panels management + panels map[uint]*WebviewPanel + panelsLock sync.RWMutex + panelResizeHandlerRegistered bool // Tracks if resize handler is registered } func (w *WebviewWindow) SetMenu(menu *Menu) { @@ -290,13 +295,15 @@ 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() - // 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) @@ -429,6 +436,9 @@ func (w *WebviewWindow) Run() { } 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. @@ -1602,3 +1612,228 @@ 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 { + globalApplication.debug("[Window] NewPanel() called", + "windowID", w.id, + "panelName", options.Name, + "windowImplNil", w.impl == nil, + "windowDestroyed", w.isDestroyed()) + + panel := NewPanel(options) + panel.parent = w + + w.panelsLock.Lock() + 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 +} + +// 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() { + 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)) + for _, panel := range w.panels { + if panel.impl == nil { + panels = append(panels, panel) + } + } + 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. +// 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() + } +}