Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
89e8d89
feat(windows): add support for custom hit-test logic for non-client r…
savely-krasovsky May 16, 2026
bc4951e
fix(windows): address review comments
savely-krasovsky May 16, 2026
2519890
refactor: simplify non-client hit test handling logic
savely-krasovsky May 16, 2026
819da92
fix: remove handling for non-client right mouse button events
savely-krasovsky May 16, 2026
8182aa4
feat(windows): add cursor handling
savely-krasovsky May 16, 2026
a6b8b4c
feat(windows): handle right-click on window caption to display the sy…
savely-krasovsky May 16, 2026
c05ddde
fix(windows): enable non-client region tracking only is the feature i…
savely-krasovsky May 16, 2026
2348d47
fix(windows): preserve native hover state for non-client buttons
savely-krasovsky May 16, 2026
29e50ea
fix(windows): address review comments
savely-krasovsky May 16, 2026
51ccf06
fix(runtime): rename app region css tracking internals
savely-krasovsky May 16, 2026
202b03d
chore(windows): document non-client hit test ordering
savely-krasovsky May 16, 2026
5266af5
feat(runtime): add runtime-config-ready event to improve initializati…
savely-krasovsky May 16, 2026
38a4ca2
Merge branch 'master' into master
savely-krasovsky May 17, 2026
a1691fc
Merge branch 'master' into master
leaanthony May 23, 2026
7038b1a
feat(windows): document `NonClientRegionSupport` and `WebView2Composi…
savely-krasovsky May 31, 2026
da3466b
docs: correct video asset path in frameless windows documentation
savely-krasovsky May 31, 2026
c655fe5
docs: clarify `WebView2CompositionHosting` experimental status
savely-krasovsky May 31, 2026
a87bf7a
docs: fix alignment issue in `WebView2CompositionHosting` example
savely-krasovsky May 31, 2026
b4d7717
docs: clarify Snap Layouts behavior and add reference to native non-c…
savely-krasovsky May 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions v3/internal/runtime/desktop/@wailsio/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
],
"sideEffects": [
"./dist/index.js",
"./dist/appregion.js",
"./dist/contextmenu.js",
"./dist/drag.js"
],
Expand Down
222 changes: 222 additions & 0 deletions v3/internal/runtime/desktop/@wailsio/runtime/src/appregion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/*
_ __ _ __
| | / /___(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/

import { invoke } from "./system.js";
import { whenReady } from "./utils.js";

type AppRegionKind = "caption" | "minimize" | "maximize" | "close";

interface AppRegion {
kind: AppRegionKind;
left: number;
top: number;
right: number;
bottom: number;
}

/*
--wails-non-client-region: caption; marks an area that can drag the window
--wails-non-client-region: minimize; marks a custom minimize button
--wails-non-client-region: maximize; marks a custom maximize button
--wails-non-client-region: close; marks a custom close button
*/
const regionProperty = "--wails-non-client-region";
const validRegions = new Set<AppRegionKind>(["caption", "minimize", "maximize", "close"]);

// Setup
window._wails = window._wails || {};

let updatePending = false;
let lastPayload = "";
let observedElements = new Set<Element>();
let resizeObserver: ResizeObserver | undefined;
let trackingStarted = false;

function normaliseRegionKind(value: string): AppRegionKind | undefined {
const region = value.trim().toLowerCase();
if (validRegions.has(region as AppRegionKind)) {
return region as AppRegionKind;
}
return undefined;
}

function appRegionForElement(element: Element): AppRegionKind | undefined {
if (!(element instanceof HTMLElement)) {
return undefined;
}

const style = window.getComputedStyle(element);
const region = normaliseRegionKind(style.getPropertyValue(regionProperty));
if (!region) {
return undefined;
}

const parent = element.parentElement;
if (parent) {
const parentStyle = window.getComputedStyle(parent);
// The CSS property is inherited. Only report the outermost element for
// each contiguous region so native hit testing sees stable rectangles.
if (normaliseRegionKind(parentStyle.getPropertyValue(regionProperty)) === region) {
return undefined;
}
}

return region;
}

function isVisible(element: HTMLElement): boolean {
const style = window.getComputedStyle(element);
return style.display !== "none" &&
style.visibility !== "hidden" &&
style.contentVisibility !== "hidden";
}

function elementRegion(element: Element): AppRegion | undefined {
if (!(element instanceof HTMLElement)) {
return undefined;
}

const kind = appRegionForElement(element);
if (!kind || !isVisible(element)) {
return undefined;
}

const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
return undefined;
}

// Native hit testing runs in physical pixels, while DOM geometry is in CSS pixels.
const scale = window.devicePixelRatio || 1;
const left = Math.floor(rect.left * scale);
const top = Math.floor(rect.top * scale);
const right = Math.ceil(rect.right * scale);
const bottom = Math.ceil(rect.bottom * scale);

if (right <= left || bottom <= top) {
return undefined;
}

return { kind, left, top, right, bottom };
}

function regionElements(): Element[] {
const elements: Element[] = [];

if (document.documentElement) {
elements.push(document.documentElement);
}
if (document.body) {
elements.push(document.body);
elements.push(...document.body.querySelectorAll("*"));
}
Comment thread
savely-krasovsky marked this conversation as resolved.

return elements;
}

function observeRegionElements(elements: Element[]): void {
if (typeof ResizeObserver === "undefined") {
return;
}

// Track size changes only for active region elements. DOM structure and style
// changes are covered by MutationObserver in startAppRegionTracking().
resizeObserver ??= new ResizeObserver(scheduleUpdate);
const nextElements = new Set(elements);

for (const element of observedElements) {
if (!nextElements.has(element)) {
resizeObserver.unobserve(element);
}
}

for (const element of nextElements) {
if (!observedElements.has(element)) {
resizeObserver.observe(element);
}
}

observedElements = nextElements;
}

function updateAppRegions(): void {
updatePending = false;

const elements = regionElements();
const regions: AppRegion[] = [];
const activeElements: Element[] = [];

for (const element of elements) {
const region = elementRegion(element);
if (region) {
regions.push(region);
activeElements.push(element);
}
}

observeRegionElements(activeElements);

const payload = JSON.stringify({ version: 1, regions });
if (payload === lastPayload) {
// Avoid sending duplicate native messages during resize or style churn.
return;
}

lastPayload = payload;
invoke("wails:non-client-region:" + payload);
}

function scheduleUpdate(): void {
if (updatePending) {
return;
}

// Batch region updates to animation frames so layout is measured once per frame.
updatePending = true;
window.requestAnimationFrame(updateAppRegions);
}

function startAppRegionTracking(): void {
if (trackingStarted) {
return;
}

trackingStarted = true;
// Send an initial empty or populated region list once the DOM is ready.
scheduleUpdate();

const mutationObserver = new MutationObserver(scheduleUpdate);
mutationObserver.observe(document.documentElement, {
attributes: true,
childList: true,
subtree: true,
});

window.addEventListener("resize", scheduleUpdate);
window.addEventListener("scroll", scheduleUpdate, true);
window.visualViewport?.addEventListener("resize", scheduleUpdate);
window.visualViewport?.addEventListener("scroll", scheduleUpdate);
}

let environmentPolls = 0;
function tryStartAppRegionTracking(): void {
const os = window._wails.environment?.OS;
if (os === "windows") {
whenReady(startAppRegionTracking);
return;
}

if (os === undefined && environmentPolls++ < 100) {
// The runtime environment can arrive after this side-effect module loads.
window.setTimeout(tryStartAppRegionTracking, 50);
}
}

tryStartAppRegionTracking();
4 changes: 4 additions & 0 deletions v3/internal/runtime/desktop/@wailsio/runtime/src/drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ function primaryDown(event: MouseEvent): void {
}

if (resizeEdge) {
if (event.type !== 'mousedown') {
return;
}

// Ready to resize if the primary button was pressed for the first time.
canResize = true;
// Do not start drag operations when on resize edges.
Expand Down
1 change: 1 addition & 0 deletions v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ window._wails = window._wails || {};

import "./contextmenu.js";
import "./drag.js";
import "./appregion.js";

// Re-export public API
import * as Application from "./application.js";
Expand Down
69 changes: 35 additions & 34 deletions v3/pkg/application/application_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -533,37 +533,38 @@ func (w *serverWebviewWindow) startDrag() error {
func (w *serverWebviewWindow) startResize(border string) error {
return errors.New("resize not available in server mode")
}
func (w *serverWebviewWindow) print() error { return errors.New("print not available in server mode") }
func (w *serverWebviewWindow) setEnabled(enabled bool) {}
func (w *serverWebviewWindow) physicalBounds() Rect { return Rect{} }
func (w *serverWebviewWindow) setPhysicalBounds(bounds Rect) {}
func (w *serverWebviewWindow) bounds() Rect { return Rect{} }
func (w *serverWebviewWindow) setBounds(bounds Rect) {}
func (w *serverWebviewWindow) position() (int, int) { return 0, 0 }
func (w *serverWebviewWindow) setPosition(x int, y int) {}
func (w *serverWebviewWindow) centerOnScreen(_ *Screen) {}
func (w *serverWebviewWindow) relativePosition() (int, int) { return 0, 0 }
func (w *serverWebviewWindow) setRelativePosition(x int, y int) {}
func (w *serverWebviewWindow) flash(enabled bool) {}
func (w *serverWebviewWindow) handleKeyEvent(acceleratorString string) {}
func (w *serverWebviewWindow) getBorderSizes() *LRTB { return &LRTB{} }
func (w *serverWebviewWindow) setMinimiseButtonState(state ButtonState) {}
func (w *serverWebviewWindow) setMaximiseButtonState(state ButtonState) {}
func (w *serverWebviewWindow) setCloseButtonState(state ButtonState) {}
func (w *serverWebviewWindow) setFullscreenButtonState(state ButtonState) {}
func (w *serverWebviewWindow) isIgnoreMouseEvents() bool { return false }
func (w *serverWebviewWindow) setIgnoreMouseEvents(ignore bool) {}
func (w *serverWebviewWindow) cut() {}
func (w *serverWebviewWindow) copy() {}
func (w *serverWebviewWindow) paste() {}
func (w *serverWebviewWindow) undo() {}
func (w *serverWebviewWindow) delete() {}
func (w *serverWebviewWindow) selectAll() {}
func (w *serverWebviewWindow) redo() {}
func (w *serverWebviewWindow) showMenuBar() {}
func (w *serverWebviewWindow) hideMenuBar() {}
func (w *serverWebviewWindow) toggleMenuBar() {}
func (w *serverWebviewWindow) setMenu(menu *Menu) {}
func (w *serverWebviewWindow) snapAssist() {}
func (w *serverWebviewWindow) attachModal(modalWindow *WebviewWindow) {}
func (w *serverWebviewWindow) setContentProtection(enabled bool) {}
func (w *serverWebviewWindow) print() error { return errors.New("print not available in server mode") }
func (w *serverWebviewWindow) setEnabled(enabled bool) {}
func (w *serverWebviewWindow) physicalBounds() Rect { return Rect{} }
func (w *serverWebviewWindow) setPhysicalBounds(bounds Rect) {}
func (w *serverWebviewWindow) bounds() Rect { return Rect{} }
func (w *serverWebviewWindow) setBounds(bounds Rect) {}
func (w *serverWebviewWindow) position() (int, int) { return 0, 0 }
func (w *serverWebviewWindow) setPosition(x int, y int) {}
func (w *serverWebviewWindow) centerOnScreen(_ *Screen) {}
func (w *serverWebviewWindow) relativePosition() (int, int) { return 0, 0 }
func (w *serverWebviewWindow) setRelativePosition(x int, y int) {}
func (w *serverWebviewWindow) flash(enabled bool) {}
func (w *serverWebviewWindow) handleKeyEvent(acceleratorString string) {}
func (w *serverWebviewWindow) getBorderSizes() *LRTB { return &LRTB{} }
func (w *serverWebviewWindow) setMinimiseButtonState(state ButtonState) {}
func (w *serverWebviewWindow) setMaximiseButtonState(state ButtonState) {}
func (w *serverWebviewWindow) setCloseButtonState(state ButtonState) {}
func (w *serverWebviewWindow) setFullscreenButtonState(state ButtonState) {}
func (w *serverWebviewWindow) isIgnoreMouseEvents() bool { return false }
func (w *serverWebviewWindow) setIgnoreMouseEvents(ignore bool) {}
func (w *serverWebviewWindow) cut() {}
func (w *serverWebviewWindow) copy() {}
func (w *serverWebviewWindow) paste() {}
func (w *serverWebviewWindow) undo() {}
func (w *serverWebviewWindow) delete() {}
func (w *serverWebviewWindow) selectAll() {}
func (w *serverWebviewWindow) redo() {}
func (w *serverWebviewWindow) showMenuBar() {}
func (w *serverWebviewWindow) hideMenuBar() {}
func (w *serverWebviewWindow) toggleMenuBar() {}
func (w *serverWebviewWindow) setMenu(menu *Menu) {}
func (w *serverWebviewWindow) snapAssist() {}
func (w *serverWebviewWindow) attachModal(modalWindow *WebviewWindow) {}
func (w *serverWebviewWindow) setContentProtection(enabled bool) {}
func (w *serverWebviewWindow) setNonClientHitTestRegions([]nonClientHitTestRegion) {}
Loading