diff --git a/docs/mocha-wallpaper-assets.md b/docs/mocha-wallpaper-assets.md new file mode 100644 index 0000000..cb5055f --- /dev/null +++ b/docs/mocha-wallpaper-assets.md @@ -0,0 +1,46 @@ +# Mocha wallpaper source configuration + +The Noctalia template points its default and DP-1 wallpaper to the supplied +3840×2160 landscape PNG, and HDMI-A-1 to the separately composed 2160×3840 +portrait PNG. Per-monitor browsing directories use the same pools. These are +source configuration and assets only; this change does not run `chezmoi apply`, +change a live session, or start/enable a timer. + +The upstream template's `[wallpaper.automation] enabled = false` remains +unchanged. With automation off and no separately selected scheduler, the +wallpapers remain static; the supplied one-image-per-orientation pools do not +provide distinct candidates for rotation. Users who want Noctalia's native +automation can explicitly set `enabled = true` in their managed configuration. + +Rotation alternatives are separate and opt-in. PR #92's +`ezwallpaper-rotation` integration uses the `ezwallpaper-mocha` pools. Local +rotation B (PR #95, documented in +[`ezwallpaper-rotation-b.md`](ezwallpaper-rotation-b.md)) has a path mismatch: +its profile template currently points to +`~/.local/share/wallpapers/{landscape,portrait}`, but its preflight guard is +hard-coded to require real directories at +`~/.local/share/wallpapers/ezwallpaper-mocha/{landscape,portrait}`. Changing +the profile alone does not make the guard support arbitrary pool paths; both +the profile's candidate paths and the guard's fixed paths must satisfy their +respective checks. Neither profile nor timer is added or enabled by this +asset/config change, and the two ezwallpaper schedulers must not run together. +B additionally requires a compatible `~/.local/bin/ezwallpaper` executable, +valid distinct candidates for rotation, and the session/config/unit safeguards; +neither that private/local binary nor additional runtime images are supplied +here. An operator selecting B must resolve the path contract, review scoped +`chezmoi diff`, establish B as the sole rotation owner, deploy its files +separately, verify the pools and session prerequisites, and ensure Noctalia's +managed automation and any GUI override are off before separately enabling B. +This change does not enable B or run its timer. + +Noctalia's native automation is a distinct alternative: users may explicitly +set its managed `enabled` flag to true. That does not enable either ezwallpaper +integration, whose preflight requires native automation to be off. If no +rotation controller is deliberately deployed, this supplied one-image-per- +orientation configuration remains static; native Noctalia automation can +instead be opted into separately. + +The B preflight is a point-in-time guard, not a lock against later config or +unit changes, and cannot prove the running Noctalia process reloaded settings +or that rendered pixels are correct. No live setter, service, timer, reload, +or apply was run while preparing this source change. diff --git a/dotfiles/dot_config/noctalia/desktop.toml.tmpl b/dotfiles/dot_config/noctalia/desktop.toml.tmpl index ed0cd89..0061dd3 100644 --- a/dotfiles/dot_config/noctalia/desktop.toml.tmpl +++ b/dotfiles/dot_config/noctalia/desktop.toml.tmpl @@ -60,21 +60,28 @@ enabled = false [wallpaper] enabled = true -directory = "{{ .chezmoi.homeDir }}/.local/share/wallpapers/omarchy" +directory = "{{ .chezmoi.homeDir }}/.local/share/wallpapers/ezwallpaper-mocha/landscape" fill_mode = "crop" transition = ["fade"] transition_duration = 900.0 transition_on_startup = true fill_color = "surface" +per_monitor_directories = true [wallpaper.default] -path = "{{ .chezmoi.homeDir }}/.local/share/wallpapers/omarchy/sunset-lake-4k.webp" +path = "{{ .chezmoi.homeDir }}/.local/share/wallpapers/ezwallpaper-mocha/landscape/mocha-flat-landscape.png" [wallpaper.monitors.DP-1] -path = "{{ .chezmoi.homeDir }}/.local/share/wallpapers/omarchy/sunset-lake-4k.webp" +path = "{{ .chezmoi.homeDir }}/.local/share/wallpapers/ezwallpaper-mocha/landscape/mocha-flat-landscape.png" [wallpaper.monitors.HDMI-A-1] -path = "{{ .chezmoi.homeDir }}/.local/share/wallpapers/omarchy/sunset-lake-portrait.webp" +path = "{{ .chezmoi.homeDir }}/.local/share/wallpapers/ezwallpaper-mocha/portrait/mocha-flat-portrait.png" + +[wallpaper.monitor.DP-1] +directory = "{{ .chezmoi.homeDir }}/.local/share/wallpapers/ezwallpaper-mocha/landscape" + +[wallpaper.monitor.HDMI-A-1] +directory = "{{ .chezmoi.homeDir }}/.local/share/wallpapers/ezwallpaper-mocha/portrait" [wallpaper.automation] enabled = false diff --git a/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/README.md b/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/README.md new file mode 100644 index 0000000..bcf0885 --- /dev/null +++ b/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/README.md @@ -0,0 +1,12 @@ +# Mocha flat wallpaper assets + +This directory contains two project-authored, flat-vector wallpapers: a +3840×2160 landscape scene and a separately composed 2160×3840 portrait scene. +Their editable SVG sources are in `source/`; the PNGs in `landscape/` and +`portrait/` are the files referenced by the Noctalia desktop template. They +use no external images, fonts, or downloaded assets. The repository MIT +license applies; no third-party artwork is bundled. + +Each runtime pool currently contains one image. This supplies stable desktop +wallpapers, not a meaningful rotation pool. Existing `landscape/`, `portrait/`, +and `omarchy/` source assets are not replaced or removed. diff --git a/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/landscape/mocha-flat-landscape.png b/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/landscape/mocha-flat-landscape.png new file mode 100644 index 0000000..f8692ca Binary files /dev/null and b/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/landscape/mocha-flat-landscape.png differ diff --git a/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/portrait/mocha-flat-portrait.png b/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/portrait/mocha-flat-portrait.png new file mode 100644 index 0000000..f87b58e Binary files /dev/null and b/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/portrait/mocha-flat-portrait.png differ diff --git a/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/source/mocha-flat-landscape.svg b/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/source/mocha-flat-landscape.svg new file mode 100644 index 0000000..7175e31 --- /dev/null +++ b/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/source/mocha-flat-landscape.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/source/mocha-flat-portrait.svg b/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/source/mocha-flat-portrait.svg new file mode 100644 index 0000000..bf0945f --- /dev/null +++ b/dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha/source/mocha-flat-portrait.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/test_mocha_wallpaper_assets.py b/tests/test_mocha_wallpaper_assets.py new file mode 100644 index 0000000..d1b44d4 --- /dev/null +++ b/tests/test_mocha_wallpaper_assets.py @@ -0,0 +1,159 @@ +"""Offline contract checks for the bundled Mocha wallpaper assets/config.""" +from __future__ import annotations + +import re +import struct +import tomllib +import unittest +import xml.etree.ElementTree as ET +import zlib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ASSETS = ROOT / "dotfiles/dot_local/share/wallpapers/ezwallpaper-mocha" +TEMPLATE = ROOT / "dotfiles/dot_config/noctalia/desktop.toml.tmpl" +DOC = ROOT / "docs/mocha-wallpaper-assets.md" +B_PROFILE = ROOT / "dotfiles/dot_config/ezwallpaper/rotation.json.tmpl" +B_PREFLIGHT = ROOT / "dotfiles/dot_local/bin/executable_ezwallpaper-rotation-preflight.py" +EXPECTED = { + "landscape/mocha-flat-landscape.png": (3840, 2160), + "portrait/mocha-flat-portrait.png": (2160, 3840), +} + + +def inspect_png(path: Path) -> tuple[int, int]: + """Validate PNG chunks/CRCs and fully decompress non-interlaced RGB data.""" + data = path.read_bytes() + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise AssertionError(f"{path} is not a PNG") + pos = 8 + width = height = bit_depth = color_type = interlace = None + compressed = bytearray() + saw_end = False + while pos < len(data): + if pos + 12 > len(data): + raise AssertionError(f"truncated PNG chunk in {path}") + length = struct.unpack_from(">I", data, pos)[0] + kind = data[pos + 4 : pos + 8] + end = pos + 8 + length + if end + 4 > len(data): + raise AssertionError(f"truncated PNG payload in {path}") + payload = data[pos + 8 : end] + expected_crc = struct.unpack_from(">I", data, end)[0] + actual_crc = zlib.crc32(kind + payload) & 0xFFFFFFFF + if actual_crc != expected_crc: + raise AssertionError(f"bad PNG CRC for {kind!r} in {path}") + if kind == b"IHDR": + if length != 13 or width is not None: + raise AssertionError("invalid or repeated PNG IHDR") + width, height, bit_depth, color_type, compression, filtering, interlace = struct.unpack( + ">IIBBBBB", payload + ) + if compression or filtering or interlace: + raise AssertionError("only non-interlaced standard PNGs are supported") + elif kind == b"IDAT": + compressed.extend(payload) + elif kind == b"IEND": + if length: + raise AssertionError("invalid PNG IEND") + saw_end = True + pos = end + 4 + break + pos = end + 4 + if not saw_end or pos != len(data) or width is None: + raise AssertionError(f"missing IEND/IHDR or trailing bytes in {path}") + if bit_depth != 8 or color_type != 2: + raise AssertionError(f"expected 8-bit RGB PNG: depth={bit_depth}, type={color_type}") + pixels = zlib.decompress(compressed) + if len(pixels) != height * (1 + width * 3): + raise AssertionError(f"unexpected decompressed scanline size in {path}") + return width, height + + +class MochaWallpaperContractTests(unittest.TestCase): + def test_assets_are_real_decodable_images_at_expected_dimensions(self) -> None: + for relative, dimensions in EXPECTED.items(): + path = ASSETS / relative + self.assertTrue(path.is_file(), f"missing referenced asset: {relative}") + self.assertFalse(path.is_symlink(), f"asset must be a regular file: {relative}") + self.assertEqual(inspect_png(path), dimensions) + + def test_svg_sources_are_self_contained_and_parse(self) -> None: + for relative in ( + "source/mocha-flat-landscape.svg", + "source/mocha-flat-portrait.svg", + ): + root = ET.parse(ASSETS / relative).getroot() + self.assertEqual(root.tag.rsplit("}", 1)[-1], "svg") + for element in root.iter(): + self.assertNotIn(element.tag.rsplit("}", 1)[-1], {"script", "image"}) + for key, value in element.attrib.items(): + if key.rsplit("}", 1)[-1] in {"href", "src"}: + self.assertFalse(re.match(r"^[a-z]+:", value, re.I), value) + + def test_rendered_wallpaper_config_references_only_supplied_mocha_paths(self) -> None: + template = TEMPLATE.read_text(encoding="utf-8") + block = template.split("[wallpaper]\n", 1)[1].split("\n[backdrop]\n", 1)[0] + paths = re.findall(r'path = "\{\{ \.chezmoi\.homeDir \}\}/\.local/share/wallpapers/([^\"]+)"', block) + self.assertEqual(paths.count("ezwallpaper-mocha/landscape/mocha-flat-landscape.png"), 2) + self.assertEqual(paths.count("ezwallpaper-mocha/portrait/mocha-flat-portrait.png"), 1) + directories = re.findall(r'directory = "\{\{ \.chezmoi\.homeDir \}\}/\.local/share/wallpapers/([^\"]+)"', block) + self.assertEqual(set(directories), {"ezwallpaper-mocha/landscape", "ezwallpaper-mocha/portrait"}) + for relative in paths: + self.assertTrue((ROOT / "dotfiles/dot_local/share/wallpapers" / relative).is_file()) + self.assertIn("per_monitor_directories = true", block) + self.assertIn("[wallpaper.monitor.DP-1]", block) + self.assertIn("[wallpaper.monitor.HDMI-A-1]", block) + self.assertIn("[wallpaper.automation]\nenabled = false", block) + + def test_docs_disclose_static_default_and_b_prerequisites(self) -> None: + doc = DOC.read_text(encoding="utf-8") + profile = B_PROFILE.read_text(encoding="utf-8") + preflight = B_PREFLIGHT.read_text(encoding="utf-8") + for path in ( + ".local/share/wallpapers/landscape", + ".local/share/wallpapers/portrait", + ): + self.assertIn(path, profile) + for path in ( + ".local/share/wallpapers/ezwallpaper-mocha/landscape", + ".local/share/wallpapers/ezwallpaper-mocha/portrait", + ): + self.assertIn(path, preflight) + self.assertIn("~/.local/share/wallpapers/{landscape,portrait}", doc) + self.assertIn( + "~/.local/share/wallpapers/ezwallpaper-mocha/{landscape,portrait}", + doc, + ) + self.assertIn("hard-coded", doc) + self.assertIn( + "does not make the guard support arbitrary pool paths", + doc, + ) + for phrase in ( + "remain static", + "native automation", + "ezwallpaper-rotation-b.md", + "private/local", + "ezwallpaper-rotation", + "wallpapers/{landscape,portrait}", + "native automation is a distinct alternative", + "cannot prove", + ): + self.assertIn(phrase, doc) + + def test_rendered_toml_wallpaper_tables_parse_and_defaults_stay_off(self) -> None: + template = TEMPLATE.read_text(encoding="utf-8") + rendered = template.replace("{{ .chezmoi.homeDir }}", "/home/test") + config = tomllib.loads(rendered) + wallpaper = config["wallpaper"] + self.assertEqual(wallpaper["directory"], "/home/test/.local/share/wallpapers/ezwallpaper-mocha/landscape") + self.assertTrue(wallpaper["per_monitor_directories"]) + self.assertEqual(wallpaper["monitors"]["DP-1"]["path"], "/home/test/.local/share/wallpapers/ezwallpaper-mocha/landscape/mocha-flat-landscape.png") + self.assertEqual(wallpaper["monitors"]["HDMI-A-1"]["path"], "/home/test/.local/share/wallpapers/ezwallpaper-mocha/portrait/mocha-flat-portrait.png") + self.assertFalse(wallpaper["automation"]["enabled"]) + + +if __name__ == "__main__": + unittest.main()