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.
This commit is contained in:
1 parent
42c3b290dd
commit
e22f0ebef9
131 files changed
+12562
-1634
No files matched your search
@@ -18,6 +18,7 @@ PAGES = [
|
||||
('installation', 'Installation', ROOT / 'docs/installation.md'),
|
||||
('packaging', 'Packages and releases', ROOT / 'docs/packaging.md'),
|
||||
('configuration', 'Configuration', ROOT / 'docs/configuration.md'),
|
||||
('panel-configuration', 'Panel configuration', ROOT / 'docs/panel-configuration.md'),
|
||||
('animation-model', 'Animation model', ROOT / 'docs/animation-model.md'),
|
||||
('shortcuts', 'Shortcuts and gestures', ROOT / 'docs/shortcuts.md'),
|
||||
('locking', 'Lock screen', ROOT / 'docs/locking.md'),
|
||||
|
||||
@@ -58,8 +58,8 @@ def main():
|
||||
raise RuntimeError((logs / "weston.log").read_text())
|
||||
time.sleep(.05)
|
||||
cases = [
|
||||
("shell-continuous", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "continuous"}),
|
||||
("shell-islands", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "islands"}),
|
||||
("shell-flat", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "flat"}),
|
||||
("shell-floating", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "floating"}),
|
||||
]
|
||||
if args.suite == "desktop":
|
||||
cases.insert(0, ("lock", "lock", {"FERESE_TEST_LOCK": "1", "FERESE_TEST_BIN_DIR": "target/debug"}))
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/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()
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Exercise real shell frame-driven motion on a private nested desktop and bus.
|
||||
|
||||
FERESE_TEST_SHELL_MOTION=1 python3 scripts/tests/test_shell_motion_isolated.py
|
||||
Set FERESE_TEST_BAR_LAYOUT=islands to also test island masks and live switching.
|
||||
Set FERESE_TEST_BAR_LAYOUT=floating to also test island masks and live switching.
|
||||
Uses release binaries by default. Requires a Wayland host, dbus-daemon and gdbus.
|
||||
Protocol traces contain only this test's shell traffic, never the host desktop.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from session_socket import ipc_environment, ipc_socket
|
||||
@@ -18,10 +19,9 @@ import unittest
|
||||
|
||||
|
||||
def panel_config(preset):
|
||||
surface, group_surface = ("solid", "none") if preset == "continuous" else ("none", "island")
|
||||
style = preset
|
||||
return f'''panel main {{
|
||||
surface "{surface}"
|
||||
group-surface "{group_surface}"
|
||||
style "{style}"
|
||||
start {{ group navigation {{ island-padding 12; item overview kind=overview; item spaces kind=workspaces; }}; }}
|
||||
center {{ group title {{ island-padding 12; item title kind=focused-window; }}; }}
|
||||
end {{ group status {{ island-padding 12; item media kind=media; item quick kind=quick-settings; item network kind=network; item audio kind=audio; item recording kind=recording; item notifications kind=notifications; item battery kind=battery; item clock kind=clock; item display kind=display-mode; }}; }}
|
||||
@@ -43,8 +43,8 @@ class ShellMotionTest(unittest.TestCase):
|
||||
runtime.mkdir(mode=0o700)
|
||||
config = root / "config/ferese"
|
||||
config.mkdir(parents=True)
|
||||
bar_layout = os.environ.get("FERESE_TEST_BAR_LAYOUT", "continuous")
|
||||
self.assertIn(bar_layout, ("continuous", "islands"))
|
||||
bar_layout = os.environ.get("FERESE_TEST_BAR_LAYOUT", "flat")
|
||||
self.assertIn(bar_layout, ("flat", "floating"))
|
||||
config_file = config / "config.kdl"
|
||||
config_file.write_text(
|
||||
'animations { speed 0.5; }\n'
|
||||
@@ -122,9 +122,18 @@ class ShellMotionTest(unittest.TestCase):
|
||||
return None
|
||||
|
||||
updates = re.findall(
|
||||
r'ferese_surface_effects_v1[@#]' + effects[-1] + r'\.set_presentation\(\d+, array\[(\d+)\],', trace)
|
||||
r'ferese_surface_effects_v1[@#]' + effects[-1] + r'\.set_panel_presentation\(array\[(\d+)\],', trace)
|
||||
return int(updates[-1]) // 20 if updates else None
|
||||
|
||||
def bar_size(surface):
|
||||
identity, start = surface
|
||||
trace = log_path.read_text()[start:]
|
||||
match = re.search(r'get_layer_surface\(new id zwlr_layer_surface_v1[@#](\d+), wl_surface[@#]' + identity + r',', trace)
|
||||
if not match:
|
||||
return None
|
||||
updates = re.findall(r'zwlr_layer_surface_v1[@#]' + match.group(1) + r'\.set_size\((\d+), (\d+)\)', trace)
|
||||
return tuple(map(int, updates[-1])) if updates else None
|
||||
|
||||
def bar_input_rectangles(surface):
|
||||
identity, start = surface
|
||||
trace = log_path.read_text()[start:]
|
||||
@@ -165,9 +174,10 @@ class ShellMotionTest(unittest.TestCase):
|
||||
wait_for(lambda: (ipc_socket(runtime)).is_socket())
|
||||
wait_for(lambda: dbus("GetServerInformation", check=False).returncode == 0)
|
||||
bar = wait_for(lambda: layer_surface("ferese-shell-top-bar"))
|
||||
if bar_layout == "islands":
|
||||
if bar_layout == "floating":
|
||||
wait_for(lambda: bar_region_count(bar) == 2)
|
||||
original_input = wait_for(lambda: bar_input_rectangles(bar))
|
||||
original_start_width = max(x + width for x, y, width, height in original_input if x < 300) - min(x for x, y, width, height in original_input if x < 300)
|
||||
time.sleep(.5)
|
||||
reply = dbus("Notify", "Ferese motion test", "0", "", "Frame cadence", "Synthetic test notification", "[]", "{}", "0").stdout
|
||||
notice = re.search(r'uint32 (\d+)', reply).group(1)
|
||||
@@ -251,26 +261,84 @@ class ShellMotionTest(unittest.TestCase):
|
||||
wait_for(lambda: destroyed(remaining[0]))
|
||||
print("Modal: rapid toggles released all native surfaces", flush=True)
|
||||
|
||||
if bar_layout == "islands":
|
||||
for layout_name, expected_count in [("continuous", 1), ("islands", None)]:
|
||||
if bar_layout == "floating":
|
||||
for layout_name, expected_count in [("flat", 1), ("floating", None)]:
|
||||
config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n' + panel_config(layout_name))
|
||||
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
wait_for(lambda: bar_region_count(bar) == (expected_count or 2))
|
||||
|
||||
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
|
||||
self.assertNotIn("panicked at", log_path.read_text())
|
||||
print("Bar: separate material regions; continuous/islands live switches passed", flush=True)
|
||||
print("Bar: separate material regions; flat/floating live switches passed", flush=True)
|
||||
|
||||
for padding in [4, 0]:
|
||||
source = config_file.read_text()
|
||||
config_file.write_text(re.sub(
|
||||
r'island-padding \d+', f'island-padding {padding}', source))
|
||||
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
expected_width = original_input[0][2] - 2 * (12 - padding)
|
||||
wait_for(lambda: len(bar_input_rectangles(bar)) == 2
|
||||
and bar_input_rectangles(bar)[0][2] == expected_width)
|
||||
expected_width = original_start_width - 2 * (12 - padding)
|
||||
def start_width():
|
||||
rectangles = [(x, width) for x, y, width, height in bar_input_rectangles(bar) if x < 300]
|
||||
return max((x + width for x, width in rectangles), default=0) - min((x for x, width in rectangles), default=0)
|
||||
wait_for(lambda: start_width() == expected_width)
|
||||
|
||||
print("Bar: live padding reductions updated the input regions", flush=True)
|
||||
if settings_binary := os.environ.get("FERESE_TEST_SETTINGS"):
|
||||
child_display = next(path for path in runtime.glob("wayland-*") if path.is_socket())
|
||||
settings_env = dict(ipc_environment(env), WAYLAND_DISPLAY=str(child_display), WINIT_UNIX_BACKEND="wayland", ICED_BACKEND="wgpu")
|
||||
settings_env.pop("WAYLAND_SOCKET", None)
|
||||
settings_env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
|
||||
settings = subprocess.Popen([str(repo / settings_binary), "--page", "bar"], env=settings_env,
|
||||
start_new_session=True, stdout=log, stderr=log)
|
||||
children.append(settings)
|
||||
def windows():
|
||||
response = subprocess.run([str(ctl), "--json", "windows"], env=ipc_environment(env),
|
||||
text=True, capture_output=True, check=True, timeout=5)
|
||||
return json.loads(response.stdout)
|
||||
wait_for(lambda: any(window["mapped"] and window["app_id"] == "dev.ferese.Settings" for window in windows()))
|
||||
config_file.write_text('status { keybinding-guide #false; }\n' + panel_config("floating").replace('style "floating"', 'style "adaptive"'))
|
||||
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
wait_for(lambda: bar_region_count(bar) == 3)
|
||||
for command, count in [("toggle-maximized", 1), ("toggle-maximized", 3)]:
|
||||
subprocess.run([str(ctl), command], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
wait_for(lambda: bar_region_count(bar) == count)
|
||||
subprocess.run([str(ctl), "toggle-fullscreen"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
wait_for(lambda: not bar_input_rectangles(bar))
|
||||
subprocess.run([str(ctl), "toggle-fullscreen"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
wait_for(lambda: bar_input_rectangles(bar))
|
||||
settings.terminate()
|
||||
settings.wait(timeout=5)
|
||||
wait_for(lambda: not windows())
|
||||
print("Adaptive: maximization changed the real panel; fullscreen released input and restored it", flush=True)
|
||||
|
||||
if bar_layout == "floating":
|
||||
for style, regions in [("floating", 2), ("capsules", 2), ("notch", 2), ("flat", 1), ("fused", 2), ("adaptive", 2)]:
|
||||
config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n' + panel_config(style))
|
||||
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
wait_for(lambda: bar_region_count(bar) == regions)
|
||||
self.assertTrue(bar_input_rectangles(bar), style + " lost its native input")
|
||||
config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\nnotifications { timeout-ms 1200; }\n' + panel_config("morph"))
|
||||
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
wait_for(lambda: bar_size(bar) == (150, 34) and bar_region_count(bar) == 1)
|
||||
reply = dbus("Notify", "Morph test", "0", "", "Hub notification", "Bounded native surface", "[]", "{}", "0").stdout
|
||||
notice = re.search(r'uint32 (\d+)', reply).group(1)
|
||||
wait_for(lambda: bar_size(bar) == (320, 48))
|
||||
# Removing a notice must collapse its hub immediately; an old timeout cannot reopen it.
|
||||
dbus("CloseNotification", notice)
|
||||
wait_for(lambda: bar_size(bar) == (150, 34))
|
||||
time.sleep(.25)
|
||||
before = commits(bar)
|
||||
trace_start = len(log_path.read_text())
|
||||
time.sleep(.5)
|
||||
# Clock/service updates can cause a short render burst; settled geometry must not animate.
|
||||
self.assertNotIn(".set_panel_presentation(", log_path.read_text()[trace_start:])
|
||||
self.assertLessEqual(commits(bar) - before, 4, "settled Morph keeps scheduling frames")
|
||||
self.assertEqual(bar_region_count(bar), 1)
|
||||
config_file.write_text('status { keybinding-guide #false; }\n' + panel_config("floating"))
|
||||
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
wait_for(lambda: bar_region_count(bar) == 2)
|
||||
print("Styles: native masks survived every live switch; Morph grew, closed and stopped frames", flush=True)
|
||||
|
||||
saved = config_file.read_text()
|
||||
config_file.write_text('panel main { surface "invalid-surface"; }\n')
|
||||
rejected = subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env),
|
||||
@@ -293,6 +361,8 @@ class ShellMotionTest(unittest.TestCase):
|
||||
self.assertNotIn("panicked at", log_path.read_text())
|
||||
print("Reload: invalid config preserved the panel; repaired config reopened and released a modal", flush=True)
|
||||
finally:
|
||||
if destination := os.environ.get("FERESE_TEST_LOG"):
|
||||
shutil.copyfile(log_path, destination)
|
||||
for child in reversed(children):
|
||||
if child.poll() is None:
|
||||
os.killpg(child.pid, signal.SIGTERM)
|
||||
|
||||
Reference in new issue
Block a user