tauri-plugin-decor 1.0.1

Opinionated window decoration controls for Tauri apps.
Documentation

let runtimeState = {
	hoverBg: "rgba(0,0,0,0.2)",
	closeHoverBg: "rgba(196,43,28,1)",
	titlebarHeight: 32,
	buttonWidth: 46,
};

function applyStyle(style) {
	if (!style || typeof style !== "object") return;
	if (typeof style.hoverBg === "string") runtimeState.hoverBg = style.hoverBg;
	if (typeof style.closeHoverBg === "string") runtimeState.closeHoverBg = style.closeHoverBg;
	if (typeof style.titlebarHeight === "number") runtimeState.titlebarHeight = style.titlebarHeight;
	if (typeof style.buttonWidth === "number") runtimeState.buttonWidth = style.buttonWidth;

	const tb = document.querySelector("[data-tauri-decor-tb]");
	if (tb) tb.style.height = `${runtimeState.titlebarHeight}px`;
	document.querySelectorAll(".decor-tb-btn").forEach((b) => {
		b.style.height = `${runtimeState.titlebarHeight}px`;
		b.style.width = `${runtimeState.buttonWidth}px`;
	});
}

function installRuntimeListener() {
	const tauri = window.__TAURI__;
	if (!tauri || !tauri.event || !tauri.event.listen) {
		setTimeout(installRuntimeListener, 10);
		return;
	}
	if (document.documentElement.dataset.tauriDecorListener === "1") return;
	document.documentElement.dataset.tauriDecorListener = "1";
	tauri.event.listen("decor://style-changed", (e) => applyStyle(e.payload || {}));
}

function createControls(config) {
	if (config.hoverBg) runtimeState.hoverBg = config.hoverBg;
	if (config.closeHoverBg) runtimeState.closeHoverBg = config.closeHoverBg;
	if (config.buttonStyle?.height) {
		const h = parseInt(config.buttonStyle.height, 10);
		if (!Number.isNaN(h)) runtimeState.titlebarHeight = h;
	}
	if (config.buttonStyle?.width) {
		const w = parseInt(config.buttonStyle.width, 10);
		if (!Number.isNaN(w)) runtimeState.buttonWidth = w;
	}

	const run = () => {
		const tauri = window.__TAURI__;
		if (!tauri) return setTimeout(run, 10);

		const win = tauri.window.getCurrentWindow();
		const invoke = tauri.core.invoke;

		const suppressWebViewContextMenu = (e) => {
			e.preventDefault();
		};

		const updateControlsInset = () => {
			const firstBtn = document.querySelector(".decor-tb-btn");
			if (!firstBtn) return;
			const insetRight = window.innerWidth - firstBtn.getBoundingClientRect().left;
			document.documentElement.style.setProperty("--tauri-decor-controls-width", `${insetRight}px`);
		};

		const setup = (tbEl) => {
			if (tbEl.querySelector(".decor-tb-btn")) return;

			const parent = config.containerStyle
				? (() => {
					const div = document.createElement("div");
					Object.assign(div.style, config.containerStyle);
					tbEl.appendChild(div);
					return div;
				})()
				: tbEl;

			config.controls.forEach(id => {
				const btn = document.createElement("button");
				btn.id = `decor-tb-${id}`;
				btn.classList.add("decor-tb-btn");
				btn.dataset.decorRole = id;

				if (config.buttonStyle) {
					Object.assign(btn.style, config.buttonStyle);
				}

				btn.innerHTML = config.icons?.[id] || "";

				if (config.ariaLabels?.[id]) {
					btn.setAttribute("aria-label", config.ariaLabels[id]);
				}

				const localState = { snapTimer: null, actionLock: false, lastAction: 0 };

				const cancelSnap = () => {
					if (localState.snapTimer) {
						clearTimeout(localState.snapTimer);
						localState.snapTimer = null;
					}
				};

				const tryAction = (action) => {
					const now = Date.now();
					if (localState.actionLock || now - localState.lastAction < 200) return;
					localState.actionLock = true;
					localState.lastAction = now;
					cancelSnap();
					Promise.resolve(action()).finally(() => {
						setTimeout(() => { localState.actionLock = false; }, 100);
					});
				};

				if (!config.cssHover) {
					btn.onmouseenter = () => {
						const hoverBg = id === "close" ? runtimeState.closeHoverBg : runtimeState.hoverBg;
						btn.style.backgroundColor = hoverBg;
						if (id === "maximize" && config.snapOverlay && !localState.actionLock) {
							cancelSnap();
							localState.snapTimer = setTimeout(() => {
								if (!localState.actionLock) {
									win.setFocus().then(() => invoke("plugin:decor|show_snap_overlay"));
								}
								localState.snapTimer = null;
							}, 620);
						}
					};

					btn.onmouseleave = () => {
						btn.style.backgroundColor = config.buttonStyle?.backgroundColor || "transparent";
						cancelSnap();
					};

					btn.onmousedown = (e) => {
						if (e.button === 0) cancelSnap();
					};
				}

				switch (id) {
					case "minimize":
						btn.onclick = (e) => { e.preventDefault(); tryAction(() => win.minimize()); };
						break;
					case "maximize":
						if (config.restoreIcon) {
							const syncMaxIcon = () => {
								win.isMaximized().then(maximized => {
									btn.innerHTML = maximized ? config.restoreIcon : (config.icons?.maximize || "");
									btn.setAttribute("aria-label", maximized ? "Restore window" : "Maximize window");
								});
							};
							syncMaxIcon();
							win.onResized(syncMaxIcon);
						}
						btn.onclick = (e) => { e.preventDefault(); tryAction(() => win.toggleMaximize()); };
						break;
					case "close":
						btn.onclick = () => win.close();
						break;
				}

				btn.addEventListener(
					"contextmenu",
					(e) => {
						suppressWebViewContextMenu(e);
						if (id === "maximize" && config.snapOverlay) {
							cancelSnap();
							setTimeout(() => {
								win
									.setFocus()
									.then(() => invoke("plugin:decor|show_snap_overlay"))
									.catch(() => {});
							}, 0);
						}
					},
					true
				);

				parent.appendChild(btn);
			});

			if (config.css) {
				const style = document.createElement("style");
				style.innerHTML = config.css;
				document.head.appendChild(style);
			}

			requestAnimationFrame(updateControlsInset);
			window.addEventListener("resize", updateControlsInset);
		};

		const tbEl = document.querySelector("[data-tauri-decor-tb]");
		if (tbEl) {
			setup(tbEl);
			return;
		}

		const observer = new MutationObserver(() => {
			const el = document.querySelector("[data-tauri-decor-tb]");
			if (el) {
				observer.disconnect();
				setup(el);
			}
		});
		observer.observe(document.body, { childList: true, subtree: true });
	};

	run();
	installRuntimeListener();
}