Files
feresaria/scripts/preview-panels.py
T
Samuel Onoja e22f0ebef9 Add panel-wide styles and the Morph hub (#26)
Panel appearance now applies to the whole panel, with seven styles: Floating, Capsules, Notch, Edge to edge, Fused islands, Adaptive and Morph.

- Share composition, geometry, and motion through the renderer-independent `ferese-panel` crate, including native materials, input regions, and Settings previews.
- Add the Morph hub with media, calendar, notifications, controls, workspace previews, hover feedback, and attached mode. Keep recording and low-battery status visible and respect window clearance.
- Cache workspace preview geometry, draw thumbnails directly, centralize Morph transitions, and keep rendering settings local to each shell instance.
- Put Record Screen in the Morph header and show the display control only when an external monitor is connected. Update panel documentation, the desktop screenshot, and native check style names.

Local validation included 660 compositor tests, 191 shell tests, isolated preview-cache checks, GPU Morph captures at 1×/1.25×/1.5×, formatting, and Clippy with warnings denied. The final header change passed all 23 Morph tests. Release workflow jobs are skipped for this PR.
2026-10-12 01:31:34 +01:00

165 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""Run the current shell and Settings in a private nested desktop."""
import argparse
import json
import os
from pathlib import Path
import re
import shlex
import shutil
import signal
import socket
import struct
import subprocess
import tempfile
import time
REPO = Path(__file__).resolve().parents[1]
def request(endpoint, command, args=None):
data = json.dumps({"version": 1, "id": 1, "type": "command", "command": command, "args": args or {}}).encode()
with socket.socket(socket.AF_UNIX) as connection:
connection.settimeout(2)
connection.connect(str(endpoint))
connection.sendall(struct.pack(">I", len(data)) + data)
def receive(count):
result = bytearray()
while len(result) < count:
part = connection.recv(count - len(result))
if not part:
raise ConnectionError("Preview compositor disconnected")
result.extend(part)
return bytes(result)
length = struct.unpack(">I", receive(4))[0]
if length > 4 * 1024 * 1024:
raise ValueError("Oversized preview response")
result = json.loads(receive(length))
if "error" in result:
raise RuntimeError(result["error"])
return result["result"]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--no-build", action="store_true", help="Use existing binaries")
parser.add_argument("--debug", action="store_true", help="Use debug builds instead of optimized release builds")
parser.add_argument("--style", default="floating", help="Initial panel style")
args = parser.parse_args()
profile = "debug" if args.debug else "release"
if not args.no_build:
command = ["cargo", "build", "-p", "ferese", "-p", "ferese-shell", "-p", "ferese-settings"]
if not args.debug:
command.append("--release")
subprocess.run(command, cwd=REPO, check=True)
binaries = {name: REPO / "target" / profile / name for name in ("ferese", "ferese-shell", "ferese-settings")}
if any(not binary.is_file() for binary in binaries.values()):
parser.error("Build the compositor, shell and Settings first")
display = Path(os.environ.get("WAYLAND_DISPLAY", ""))
if not os.environ.get("WAYLAND_DISPLAY"):
parser.error("Run this preview inside a Wayland session")
if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
children = []
with tempfile.TemporaryDirectory(prefix="ferese-panel-preview-") as temporary:
root = Path(temporary)
# Keep sibling validation and apps opened from the shell on this build,
# even if Cargo replaces target/release while the preview is running.
preview_bin = root / "bin"
preview_bin.mkdir()
for name, binary in binaries.items():
shutil.copy2(binary, preview_bin / name)
for name in ("ferese-record", "ferese-lock", "ferese-launcher", "feresectl"):
binary = REPO / "target" / profile / name
if binary.is_file():
shutil.copy2(binary, preview_bin / name)
binaries = {name: preview_bin / name for name in binaries}
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese"
config.mkdir(parents=True)
# Keep the complete composition; an authored panel with no groups is empty.
source = (REPO / "packaging/config.kdl").read_text().replace('style "flat"', 'style ' + json.dumps(args.style), 1)
if args.style == "morph":
source = source.replace('border #false', 'border #true', 1)
source = re.sub(r'keybinding-guide\s+#true', 'keybinding-guide #false', source)
config_path = config / "config.kdl"
config_path.write_text(source)
subprocess.run([str(binaries["ferese"]), "--check-config", str(config_path)], check=True)
env = {key: value for key, value in os.environ.items() if not key.startswith("FERESE_")}
env["PATH"] = str(preview_bin) + os.pathsep + env.get("PATH", os.defpath)
env.update(XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY=str(display), XDG_CONFIG_HOME=str(root / "config"),
XDG_STATE_HOME=str(root / "state"), XDG_CACHE_HOME=str(root / "cache"),
XDG_DATA_HOME=str(root / "data"), FERESE_ENABLE_SCREENCOPY="1",
FERESE_COMPOSITOR_WALLPAPER="1", WINIT_UNIX_BACKEND="wayland", ICED_BACKEND="wgpu", RUST_LOG="ferese=info")
env.pop("WAYLAND_SOCKET", None)
bus_config = root / "bus.conf"
bus_config.write_text('<busconfig><type>session</type><listen>unix:tmpdir=/tmp</listen>'
'<policy context="default"><allow send_destination="*"/>'
'<allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
launcher = root / "launch.sh"
launcher.write_text('#!/bin/bash\nunset DISPLAY\nexport WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY}"\n'
'env -u WAYLAND_SOCKET -u FERESE_SHELL_CONTROL_SOCKET -u DISPLAY '
'WINIT_UNIX_BACKEND=wayland '
+ shlex.quote(str(binaries["ferese-settings"])) + ' --page bar &\nexec '
+ shlex.quote(str(binaries["ferese-shell"])) + '\n')
launcher.chmod(0o700)
log_path = root / "session.log"
def stop(_signal, _frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, stop)
try:
with log_path.open("w") as log:
bus = subprocess.Popen(["dbus-daemon", "--config-file=" + str(bus_config), "--nofork", "--print-address=1"],
env=env, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True)
children.append(bus)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
compositor = subprocess.Popen([str(binaries["ferese"]), "--backend", "nested", "--grant-effects",
"--grant-shell-control", "--", str(launcher)],
env=env, stdout=log, stderr=log, start_new_session=True)
children.append(compositor)
deadline = time.monotonic() + 30
readiness = "Waiting for the compositor socket"
while True:
endpoints = list(runtime.glob("ferese/instances/*/control.sock"))
trace = log_path.read_text()
child = re.search(r'spawned Wayland client.*pid=(\d+)', trace)
if compositor.poll() is not None or time.monotonic() >= deadline:
raise RuntimeError("Preview did not create its shell and Settings: " + readiness + "\n" + trace[-8000:])
if endpoints and child:
try:
panel = request(endpoints[0], "has-client-surfaces", {"pid": int(child.group(1))})
# The private socketpair has the compositor's peer credentials.
# Wallpaper is disabled, so this client can only own shell surfaces.
panel = panel or request(endpoints[0], "has-client-surfaces", {"pid": compositor.pid})
windows = request(endpoints[0], "get-windows")
settings = any(window["mapped"] and window["app_id"] == "dev.ferese.Settings"
for window in windows)
readiness = f"shell surfaces={panel}; windows={windows}"
if panel and settings:
break
except (OSError, ConnectionError):
pass
time.sleep(.05)
print(f"Panel preview ready ({profile} build). Changes apply only inside this window.\nConfig: {config_path}\nLog: {log_path}", flush=True)
compositor.wait()
except KeyboardInterrupt:
pass
finally:
for child in reversed(children):
try:
os.killpg(child.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
child.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait(timeout=5)
if child.stdout is not None:
child.stdout.close()
if __name__ == "__main__":
main()