diff options
| author | Adam Malczewski <[email protected]> | 2026-06-15 20:04:15 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-15 20:04:15 +0900 |
| commit | 310ef9b53f0ab8396e15e6f9e077df5399508539 (patch) | |
| tree | a20c412c63199abb697054d0850a471ea6c37417 | |
| parent | 143cb713b685a9089e85c90a932fb79efa606dc6 (diff) | |
| download | unbox-310ef9b53f0ab8396e15e6f9e077df5399508539.tar.gz unbox-310ef9b53f0ab8396e15e6f9e077df5399508539.zip | |
ext-wallpaper: config-driven desktop wallpaper (new standard-tier unit)
A new standard-tier extension that composites a desktop background in the
'background' scene layer, below every window. Reads [wallpaper] from unbox.toml
(path / fit = cover|contain|stretch|center / color), hot-reloaded via watch_file
(drop + recreate an inline RCSS document with the values baked in -- avoids
fragile decorator data-binding). The surface is input_transparent so it never
steals clicks. Pure doctested config core; factory-only contract.
When no path is configured it falls back to a bundled default image
(assets/ext-wallpaper/default.jpg, auto-installed to the data dir), resolved to an
absolute path via $UNBOX_ASSET_DIR or the UNBOX_ASSET_DIR_DEFAULT compile define.
Degrades gracefully (color-only) if there is no GL path or the file is missing.
Sized to the primary output (multi-output is a documented gap).
Wiring (composition root): root meson subdir, host-bin installs it unconditionally
with the config path, unbox.toml gains a documented [wallpaper] sample.
(swaybg via ext-layer-shell's background band keeps working too.)
| -rw-r--r-- | assets/ext-wallpaper/default.jpg | bin | 0 -> 7503872 bytes | |||
| -rw-r--r-- | meson.build | 1 | ||||
| -rw-r--r-- | packages/ext-wallpaper/include/unbox/ext-wallpaper/ext_wallpaper.hpp | 46 | ||||
| -rw-r--r-- | packages/ext-wallpaper/meson.build | 73 | ||||
| -rw-r--r-- | packages/ext-wallpaper/src/config.cpp | 140 | ||||
| -rw-r--r-- | packages/ext-wallpaper/src/config.hpp | 77 | ||||
| -rw-r--r-- | packages/ext-wallpaper/src/extension.cpp | 378 | ||||
| -rw-r--r-- | packages/ext-wallpaper/tests/test_config.cpp | 182 | ||||
| -rw-r--r-- | packages/ext-wallpaper/tests/test_wallpaper.cpp | 22 | ||||
| -rw-r--r-- | packages/host-bin/meson.build | 2 | ||||
| -rw-r--r-- | packages/host-bin/src/main.cpp | 6 | ||||
| -rw-r--r-- | unbox.toml | 19 |
12 files changed, 945 insertions, 1 deletions
diff --git a/assets/ext-wallpaper/default.jpg b/assets/ext-wallpaper/default.jpg Binary files differnew file mode 100644 index 0000000..485d429 --- /dev/null +++ b/assets/ext-wallpaper/default.jpg diff --git a/meson.build b/meson.build index 102d8ab..ed0ea0f 100644 --- a/meson.build +++ b/meson.build @@ -63,4 +63,5 @@ subdir('packages/ext-layer-shell') subdir('packages/ext-stage-dock') subdir('packages/ext-keybindings') subdir('packages/ext-window-field') +subdir('packages/ext-wallpaper') subdir('packages/host-bin') diff --git a/packages/ext-wallpaper/include/unbox/ext-wallpaper/ext_wallpaper.hpp b/packages/ext-wallpaper/include/unbox/ext-wallpaper/ext_wallpaper.hpp new file mode 100644 index 0000000..9532528 --- /dev/null +++ b/packages/ext-wallpaper/include/unbox/ext-wallpaper/ext_wallpaper.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include <unbox/kernel/extension.hpp> + +#include <memory> +#include <optional> +#include <string> + +// ext-wallpaper — config-driven desktop background image (GLOSSARY: "wallpaper"). +// A standard-tier extension that composites a solid colour and/or an image file +// at SceneLayer::background, below every application window. The image and its +// fit mode are driven by the [wallpaper] section of unbox.toml; the effective +// config file is hot-watched so edits take effect without a restart. +// +// DEFAULT IMAGE FALLBACK: when [wallpaper] path is absent or empty, the +// extension uses the bundled default image: +// <asset_root>/ext-wallpaper/default.jpg +// where asset_root = $UNBOX_ASSET_DIR ?? UNBOX_ASSET_DIR_DEFAULT ?? ".". +// If that file does not exist (e.g. running uninstalled with no UNBOX_ASSET_DIR), +// the surface shows only the solid color — no crash, one log message. +// +// The surface is ALWAYS input_transparent (never steals clicks from windows +// above it) and sized to the primary output. +// +// This header is the unit's ENTIRE cross-extension contract: a factory only +// (a leaf — exports no hooks or services). Manifest: +// { id "wallpaper", tier standard, depends_on {} } +// +// Single wl_event_loop thread throughout. + +namespace unbox::ext_wallpaper { + +// Construct the extension. Cheap and side-effect free (per the Extension +// contract); ALL wiring happens in activate(). +// +// config_path: the explicit unbox.toml path (host-bin --config). If nullopt, +// activate() discovers $XDG_CONFIG_HOME/unbox/unbox.toml then +// ~/.config/unbox/unbox.toml (same discovery as ext-window-field). The +// [wallpaper] table sets image path, fit mode, and background colour; a +// missing/malformed config falls back to compiled defaults (no image, black +// background, fit=cover). The effective file is watched for live hot-reload +// (drop + recreate the ui surface with baked values on change). +[[nodiscard]] auto create(std::optional<std::string> config_path = std::nullopt) + -> std::unique_ptr<kernel::Extension>; + +} // namespace unbox::ext_wallpaper diff --git a/packages/ext-wallpaper/meson.build b/packages/ext-wallpaper/meson.build new file mode 100644 index 0000000..596ff85 --- /dev/null +++ b/packages/ext-wallpaper/meson.build @@ -0,0 +1,73 @@ +# ext-wallpaper — config-driven desktop background (GLOSSARY: "wallpaper"). +# Standard-tier extension. Composites a solid colour + optional image file at +# SceneLayer::background (below everything else). Image path, fit mode, and +# background colour are driven by the [wallpaper] section of unbox.toml; +# hot-reloaded via Host::watch_file by DROP + RECREATE of the ui surface. +# input_transparent = true always (never steals clicks from windows above). +# Depends only on the kernel (Host::ui(), output events, watch_file). + +ext_wallpaper_inc = include_directories('include') + +# toml++: the APPROVED config dep (notes/plan.md §2), a project-global Meson wrap +# (subprojects/tomlplusplus.wrap). Force default_library=static so the installed +# `unbox` binary bakes it in (the wrap's own meson.build hardcodes shared, which +# would need libtomlplusplus.so at runtime). Same rationale as ext-keybindings. +tomlplusplus_dep = dependency('tomlplusplus', + default_options: ['default_library=static']) + +# Glue library: needs the kernel ABI (ui substrate, output events, watch_file) +# and toml++ (the [wallpaper] config loader, src/config.cpp). The config parse +# is a PURE core in src/config.{hpp,cpp} (toml++ only; doctest-covered). +ext_wallpaper_lib = static_library( + 'unbox-ext-wallpaper', + 'src/extension.cpp', + 'src/config.cpp', + include_directories: ext_wallpaper_inc, + dependencies: [kernel_dep, tomlplusplus_dep], +) + +# What host-bin links against (the factory). kernel_dep rides through for the +# Extension ABI the factory returns. +ext_wallpaper_dep = declare_dependency( + link_with: ext_wallpaper_lib, + include_directories: ext_wallpaper_inc, + dependencies: [kernel_dep], +) + +# Tests, asymmetric (AGENTS.md: strict cores, lenient shell). +# +# CONFIG core test: the pure [wallpaper] toml parser (defaults, every fit +# value, unknown fit -> cover, missing table, missing/empty path, bad color -> +# default). No kernel/wlroots; config.cpp compiles directly into the test TU. +ext_wallpaper_config_test = executable( + 'ext-wallpaper-config-tests', + 'tests/test_config.cpp', + 'src/config.cpp', + include_directories: [ext_wallpaper_inc, include_directories('src')], + dependencies: [doctest_dep, tomlplusplus_dep], +) +test( + 'ext-wallpaper-config', + ext_wallpaper_config_test, + suite: 'ext-wallpaper', +) + +# SMOKE test: the factory yields the wallpaper extension with the right manifest. +# Cheap, no kernel/wlroots running. +ext_wallpaper_smoke_test = executable( + 'ext-wallpaper-smoke-tests', + 'tests/test_wallpaper.cpp', + include_directories: [ext_wallpaper_inc, include_directories('src')], + dependencies: [ext_wallpaper_dep, doctest_dep], +) +test( + 'ext-wallpaper-smoke', + ext_wallpaper_smoke_test, + suite: 'ext-wallpaper', +) + +# Aggregate alias: `ninja -C build ext-wallpaper-tests`. +alias_target('ext-wallpaper-tests', + ext_wallpaper_config_test, + ext_wallpaper_smoke_test, +) diff --git a/packages/ext-wallpaper/src/config.cpp b/packages/ext-wallpaper/src/config.cpp new file mode 100644 index 0000000..2f4f2a1 --- /dev/null +++ b/packages/ext-wallpaper/src/config.cpp @@ -0,0 +1,140 @@ +#include "config.hpp" + +#include <toml++/toml.hpp> + +#include <optional> +#include <string> +#include <string_view> + +// Pure toml loader for the [wallpaper] config. No wlroots, no kernel, no I/O +// (the glue reads the file and hands us the text). Per-key validation, defaults +// on absence/invalid, every problem recorded as a warning, never throws. + +namespace unbox::ext_wallpaper::config { + +namespace { + +// Map a fit string to the enum. nullopt for an unknown value. +auto parse_fit(std::string_view s) -> std::optional<FitMode> { + if (s == "cover") { + return FitMode::cover; + } + if (s == "contain") { + return FitMode::contain; + } + if (s == "stretch") { + return FitMode::stretch; + } + if (s == "center") { + return FitMode::center; + } + return std::nullopt; +} + +// Very light CSS hex colour validation: must start with '#' and have 6 or 3 +// hex digits following (e.g. "#1e1e2e" or "#fff"). Returns true if valid. +auto is_valid_color(std::string_view s) -> bool { + if (s.empty() || s[0] != '#') { + return false; + } + const std::string_view digits = s.substr(1); + if (digits.size() != 6 && digits.size() != 3) { + return false; + } + for (const char c : digits) { + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + return false; + } + } + return true; +} + +} // namespace + +auto load_from_string(std::string_view toml_text) -> LoadResult { + LoadResult result; // cfg defaults already set + + toml::table tbl; + try { + tbl = toml::parse(toml_text); + } catch (const toml::parse_error& e) { + result.parse_error = true; + result.warnings.emplace_back(std::string("[wallpaper] config parse error: ") + + std::string(e.description())); + return result; // defaults stand + } + + const toml::node* section = tbl.get("wallpaper"); + if (section == nullptr) { + return result; // no table => defaults, not an error + } + const toml::table* wp = section->as_table(); + if (wp == nullptr) { + result.warnings.emplace_back("[wallpaper] is not a table; using defaults"); + return result; + } + + // path (string; empty or absent = no image — not an error). + if (const toml::node* p = wp->get("path"); p != nullptr) { + if (const auto* s = p->as_string()) { + result.cfg.path = s->get(); // may be empty — glue treats that as "no image" + } else { + result.warnings.emplace_back("[wallpaper] path must be a string; using empty (no image)"); + } + } + + // fit (string enum; unknown -> cover + warn). + if (const toml::node* f = wp->get("fit"); f != nullptr) { + if (const auto* s = f->as_string()) { + if (const auto parsed = parse_fit(s->get())) { + result.cfg.fit = *parsed; + } else { + result.warnings.emplace_back( + "[wallpaper] fit '" + s->get() + + "' is not one of cover|contain|stretch|center; using 'cover'"); + } + } else { + result.warnings.emplace_back( + "[wallpaper] fit must be a string; using 'cover'"); + } + } + + // color (CSS hex string; bad value -> default + warn). + if (const toml::node* c = wp->get("color"); c != nullptr) { + if (const auto* s = c->as_string()) { + if (is_valid_color(s->get())) { + result.cfg.color = s->get(); + } else { + result.warnings.emplace_back( + "[wallpaper] color '" + s->get() + + "' is not a valid CSS hex colour (e.g. \"#1e1e2e\"); using '#000000'"); + } + } else { + result.warnings.emplace_back( + "[wallpaper] color must be a string; using '#000000'"); + } + } + + return result; +} + +auto rmlui_fit_keyword(FitMode fit) -> std::string_view { + switch (fit) { + case FitMode::cover: return "cover"; + case FitMode::contain: return "contain"; + case FitMode::stretch: return "fill"; + case FitMode::center: return "scale-none"; + } + return "cover"; // unreachable but keeps the compiler happy +} + +auto default_image_path(std::string_view asset_root) -> std::string { + // Strip a trailing slash from asset_root (if any) for clean joining. + std::string root{asset_root}; + while (!root.empty() && root.back() == '/') { + root.pop_back(); + } + return root + "/ext-wallpaper/default.jpg"; +} + +} // namespace unbox::ext_wallpaper::config diff --git a/packages/ext-wallpaper/src/config.hpp b/packages/ext-wallpaper/src/config.hpp new file mode 100644 index 0000000..90bbaed --- /dev/null +++ b/packages/ext-wallpaper/src/config.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include <string> +#include <string_view> +#include <vector> + +// Pure decision core: the wallpaper config loader. Parses an unbox.toml +// document (as TEXT — file discovery/reading is an effect kept in the glue) +// into a WallpaperConfig. toml++ is the only dependency; no wlroots, no +// kernel. Doctest-covered in tests/test_config.cpp. +// +// SCHEMA (the [wallpaper] table; every key optional — absent => default): +// [wallpaper] +// path = "/home/me/pic.jpg" # absolute path to an image file; empty/missing = use default +// fit = "cover" # cover | contain | stretch | center (default cover) +// color = "#1e1e2e" # solid fill behind/around the image (default "#000000") +// +// DEFAULT IMAGE FALLBACK: when path is absent or empty, the GLUE resolves the +// bundled default image: <asset_root>/ext-wallpaper/default.jpg, where +// asset_root = $UNBOX_ASSET_DIR (env) ?? UNBOX_ASSET_DIR_DEFAULT (compiled) ?? ".". +// If the resolved file does not exist, the surface shows only the solid color. +// The pure helper `default_image_path(asset_root)` builds the path from a given +// root string (no I/O; the glue reads the env and checks existence). +// +// fit semantics (maps to RmlUi image-decorator fit keywords): +// cover -> cover (crop to fill the surface, no bars) +// contain -> contain (letterbox, keeps aspect) +// stretch -> fill (distort to fill) +// center -> scale-none (natural size, centred) +// An unknown fit value falls back to cover with a warning. +// A bad color value falls back to "#000000" with a warning. + +namespace unbox::ext_wallpaper::config { + +// How the image is fitted into the surface. +enum class FitMode { cover, contain, stretch, center }; + +// The parsed wallpaper configuration. Defaults are the compiled-in fallback +// used when no config / no [wallpaper] table / a bad value is present. +struct WallpaperConfig { + std::string path{}; // absolute path to the image; empty = no image + FitMode fit = FitMode::cover; // default: cover + std::string color{"#000000"}; // background colour (CSS hex); default black +}; + +// The outcome of loading. `cfg` is always populated (defaults where absent or +// invalid). `warnings` holds one message per bad value or a single parse-error +// message. `parse_error` is true iff the document failed to parse (toml syntax +// error) — then `cfg` is the pure default. NEVER throws (toml parse error is +// caught here). An empty document / absent [wallpaper] is NOT an error — it +// yields defaults with parse_error=false and no warnings. +struct LoadResult { + WallpaperConfig cfg; + std::vector<std::string> warnings; + bool parse_error = false; +}; + +// Parse `toml_text` and extract the [wallpaper] config per the schema above. +// Each key is validated independently: an unknown/non-string fit, a +// non-string path, a non-string/empty color falls back to that field's +// default with a warning and never aborts the rest. +[[nodiscard]] auto load_from_string(std::string_view toml_text) -> LoadResult; + +// Map FitMode to the RmlUi image-decorator fit keyword. +// cover -> "cover" +// contain -> "contain" +// stretch -> "fill" +// center -> "scale-none" +[[nodiscard]] auto rmlui_fit_keyword(FitMode fit) -> std::string_view; + +// Pure path-join: given the asset root directory (no trailing slash required), +// return the absolute path of the bundled default wallpaper image: +// <asset_root>/ext-wallpaper/default.jpg +// No I/O; no env reads — the caller (glue) supplies the root and checks existence. +[[nodiscard]] auto default_image_path(std::string_view asset_root) -> std::string; + +} // namespace unbox::ext_wallpaper::config diff --git a/packages/ext-wallpaper/src/extension.cpp b/packages/ext-wallpaper/src/extension.cpp new file mode 100644 index 0000000..dfc1709 --- /dev/null +++ b/packages/ext-wallpaper/src/extension.cpp @@ -0,0 +1,378 @@ +#include <unbox/ext-wallpaper/ext_wallpaper.hpp> + +#include "config.hpp" + +#include <unbox/kernel/extension.hpp> +#include <unbox/kernel/host.hpp> +#include <unbox/kernel/ui.hpp> +#include <unbox/kernel/wlr.hpp> + +#include <cstdlib> +#include <fstream> +#include <memory> +#include <optional> +#include <sstream> +#include <string> + +// ext-wallpaper glue (standard tier, GLOSSARY: "wallpaper"). +// Composites a config-driven background image + solid colour at +// SceneLayer::background, below every application window. +// +// The design is deliberately simple — it is a LEAF (no hooks, no services): +// +// activate() +// -> discover + load config +// -> create_surface() with an inline-baked RML document +// -> watch the config file (hold FileWatch as a member) +// -> subscribe to on_output_added / on_output_removed (RAII Subscriptions) +// +// DEFAULT IMAGE FALLBACK: when cfg_.path is empty, the glue resolves the +// bundled default image via resolve_asset_root() (reads $UNBOX_ASSET_DIR, +// falls back to UNBOX_ASSET_DIR_DEFAULT compile-time define, then ".") and +// config::default_image_path(root). If the file does not exist at that path +// (e.g. running uninstalled with no UNBOX_ASSET_DIR), the surface shows only +// the solid cfg_.color — one warning is logged and we never crash. +// +// Hot-reload on config change: DROP the old surface and CREATE a new one with +// freshly-baked values. This avoids the data-binding/decorator-path flakiness +// described in the brief; the surface is always baked with the current values. +// The drop+create path is also taken on the first output-added event if the +// surface could not be created (no output at activate time is a valid state; +// the output-added event drives the first sizing). +// +// If create_surface() returns null (no GL backend, e.g. headless pixman), +// we degrade gracefully: log a message and carry on without a surface. NEVER +// throw out of activate() for this reason — the brief is explicit. +// +// Input transparent: the surface is created with input_transparent=true so it +// NEVER steals clicks from windows or other surfaces above it. +// +// Lifetime discipline (listener-lifetime.md): RAII members released in +// reverse declaration order: subscriptions first (they may fire their last +// event into the surface), then surface (dropped while host_ borrow is still +// valid), then everything else. No manual teardown. + +namespace unbox::ext_wallpaper { +namespace { + +using kernel::Host; + +// ---- config helpers (effects: file I/O lives here; pure parse in config.cpp) - + +auto read_file(const std::string& path, std::string& out) -> bool { + std::ifstream in(path, std::ios::binary); + if (!in) { + return false; + } + std::ostringstream ss; + ss << in.rdbuf(); + out = ss.str(); + return true; +} + +auto discover_config_path(const std::optional<std::string>& explicit_path) + -> std::optional<std::string> { + if (explicit_path) { + return explicit_path; + } + if (const char* xdg = std::getenv("XDG_CONFIG_HOME"); xdg != nullptr && xdg[0] != '\0') { + return std::string(xdg) + "/unbox/unbox.toml"; + } + if (const char* home = std::getenv("HOME"); home != nullptr && home[0] != '\0') { + return std::string(home) + "/.config/unbox/unbox.toml"; + } + return std::nullopt; +} + +// Load the wallpaper config from the effective path. Logs every warning. +// Called both at activate() and on hot-reload. Never throws. +auto load_config(const std::optional<std::string>& effective_path) -> config::WallpaperConfig { + if (!effective_path) { + wlr_log(WLR_INFO, "ext-wallpaper: no config path; using defaults"); + return config::WallpaperConfig{}; + } + std::string text; + if (!read_file(*effective_path, text)) { + wlr_log(WLR_INFO, "ext-wallpaper: no config at '%s'; using defaults", + effective_path->c_str()); + return config::WallpaperConfig{}; + } + config::LoadResult loaded = config::load_from_string(text); + for (const std::string& w : loaded.warnings) { + wlr_log(WLR_ERROR, "ext-wallpaper: %s", w.c_str()); + } + return loaded.cfg; +} + +// ---- default asset path resolution (glue — effectful) ------------------------ +// +// Mirror how the kernel substrate resolves a relative rml_path (ui.hpp): +// 1. $UNBOX_ASSET_DIR env var (set by the dev launch / test harness) +// 2. UNBOX_ASSET_DIR_DEFAULT compile-time macro (root meson.build -D define) +// 3. "." (process working directory — last resort) +// The pure path-join (config::default_image_path) is in config.cpp and is +// doctested; the env read stays here in the glue. + +auto resolve_asset_root() -> std::string { + if (const char* env = std::getenv("UNBOX_ASSET_DIR"); + env != nullptr && env[0] != '\0') { + return std::string(env); + } +#ifdef UNBOX_ASSET_DIR_DEFAULT + return std::string(UNBOX_ASSET_DIR_DEFAULT); +#else + return std::string("."); +#endif +} + +// Cheap existence check: try opening the file for reading. +auto file_exists(const std::string& path) -> bool { + std::ifstream f(path); + return f.good(); +} + +// ---- inline RML builder ------------------------------------------------------- +// +// Build a self-contained inline RML document that paints `color` as the body +// background and, when `image_path` is non-empty, applies the image as an +// image decorator using the given RmlUi fit keyword. +// +// We bake the values directly into the document text so we never need to fight +// data-binding / decorator-path binding (which the brief notes is unproven for +// decorators). On every config change we drop the old surface and create a new +// one with a freshly-baked document. +// +// RCSS image-decorator syntax (RmlUi docs): +// decorator: image(<path> <fit> <alignment>) [, …]; +// where fit is one of: cover | contain | fill | scale-none | … +// We supply center-center alignment in all cases (ignored by cover/contain but +// required by scale-none to centre the image). + +auto build_rml(const std::string& image_path, + const std::string& fit_keyword, + const std::string& color) -> std::string { + std::string doc; + doc.reserve(512); + doc += "<rml><head><style>\n"; + doc += "body {\n"; + doc += " width: 100%;\n"; + doc += " height: 100%;\n"; + doc += " margin: 0;\n"; + doc += " padding: 0;\n"; + doc += " background-color: " + color + ";\n"; + if (!image_path.empty()) { + // decorator: image('<path>' <fit> center center) + // Fit keyword mapping (config.hpp rmlui_fit_keyword): + // cover -> cover + // contain -> contain + // stretch -> fill + // center -> scale-none + doc += " decorator: image('" + image_path + "' " + fit_keyword + " center center);\n"; + } + doc += "}\n"; + doc += "</style></head><body></body></rml>\n"; + return doc; +} + +// ---- WallpaperExtension ------------------------------------------------------- + +class WallpaperExtension final : public kernel::Extension { +public: + explicit WallpaperExtension(std::optional<std::string> config_path) + : config_path_(std::move(config_path)), + effective_path_(discover_config_path(config_path_)), + cfg_(load_config(effective_path_)) {} + + [[nodiscard]] auto manifest() const -> const kernel::Manifest& override { return manifest_; } + + void activate(Host& host) override { + host_ = &host; + + // Subscribe to output events (RAII — released before surface_ at teardown). + // The RAII ordering is: sub_ members declared after surface_, so they are + // destroyed FIRST. We declare them after surface_ (see member declarations). + // On add: size the surface (or create it if it was null due to no-GL on + // first attempt). On remove: nothing — the surface stays (the next add will + // resize it). This is the Wave-1 / primary-output posture; multi-output is + // a documented gap. + output_added_sub_ = host.subscribe( + host.on_output_added(), + [this](const kernel::OutputEvent& ev) { on_output_added(ev); }); + output_removed_sub_ = host.subscribe( + host.on_output_removed(), + [this](const kernel::OutputEvent& /*ev*/) { + // Primary-output only (Wave 1). If the primary is removed we have + // no output to show on; keep the surface but don't crash. + wlr_log(WLR_INFO, "ext-wallpaper: output removed (multi-output not yet supported)"); + }); + + // Arm hot-reload watch on the effective config path. Holds a FileWatch + // member so it lives exactly as long as this extension. + if (effective_path_) { + config_watch_ = host.watch_file(*effective_path_, [this] { reload(); }); + } + + // Create the initial wallpaper surface sized to the primary output (if + // one already exists). If not, on_output_added will create it. + create_or_replace_surface(primary_output_box()); + } + +private: + // ---- output sizing -------------------------------------------------------- + + void on_output_added(const kernel::OutputEvent& /*ev*/) { + // Re-query the primary output box (the new output might BE the primary). + const wlr_box box = primary_output_box(); + if (box.width <= 0 || box.height <= 0) { + return; + } + if (surface_ == nullptr) { + // Either no GL backend materialised yet, or we never had an output. + // Try again now. + create_or_replace_surface(box); + } else { + surface_->set_position(box.x, box.y); + surface_->set_size(box.width, box.height); + } + } + + [[nodiscard]] auto primary_output_box() const -> wlr_box { + wlr_box box{}; + if (host_ == nullptr) { + return box; + } + wlr_output_layout* ol = host_->output_layout(); + if (ol == nullptr) { + return box; + } + wlr_output_layout_output* lo = nullptr; + // Wave-1: size to the first (primary) output only. + wl_list_for_each(lo, &ol->outputs, link) { + wlr_box b{}; + wlr_output_layout_get_box(ol, lo->output, &b); + if (!wlr_box_empty(&b)) { + box = b; + } + break; // primary only + } + return box; + } + + // ---- surface management --------------------------------------------------- + + // Build a surface from the current cfg_ and the given output geometry. + // If create_surface() returns null (no GL path / headless) we log and + // return without crashing. On a config hot-reload we always drop the old + // surface first (drop-and-recreate approach). + void create_or_replace_surface(const wlr_box& box) { + if (host_ == nullptr) { + return; + } + + // Drop old surface first (may happen on hot-reload or first-output-added). + surface_.reset(); + + // Resolve the effective image path: use cfg_.path when non-empty; + // otherwise fall back to the bundled default. The pure path-join lives + // in config::default_image_path; the env read + existence check is here + // (glue — effectful). If the default file is also absent, degrade to + // colour-only (one warning; no crash). + std::string effective_image = cfg_.path; + if (effective_image.empty()) { + const std::string default_path = + config::default_image_path(resolve_asset_root()); + if (file_exists(default_path)) { + effective_image = default_path; + } else { + wlr_log(WLR_INFO, + "ext-wallpaper: no image configured and default not found at '%s'; " + "showing solid colour only", + default_path.c_str()); + } + } + + const std::string fit_kw{config::rmlui_fit_keyword(cfg_.fit)}; + const std::string doc = build_rml(effective_image, fit_kw, cfg_.color); + + kernel::UiSurfaceSpec spec; + spec.rml_inline = doc; + spec.model = "ui"; + spec.x = box.x; + spec.y = box.y; + // Non-positive dimensions are rejected by the substrate; clamp to >= 1 + // (size_to_ via on_output_added will resize to the real box). + spec.width = std::max(1, box.width); + spec.height = std::max(1, box.height); + spec.layer = kernel::SceneLayer::background; + spec.visible = true; + // REQUIRED for a wallpaper: must NEVER capture pointer/touch input. + spec.input_transparent = true; + + surface_ = host_->ui().create_surface(spec); + if (surface_ == nullptr) { + wlr_log(WLR_INFO, + "ext-wallpaper: ui substrate unavailable (no GL path?); " + "running without wallpaper surface"); + } + } + + // ---- hot-reload ----------------------------------------------------------- + + void reload() { + if (!effective_path_) { + return; + } + cfg_ = load_config(effective_path_); + wlr_log(WLR_INFO, "ext-wallpaper: config reloaded from '%s'", + effective_path_->c_str()); + // Drop + recreate the surface with baked values (robust path; avoids + // decorator data-binding flakiness noted in the brief). + const wlr_box box = primary_output_box(); + create_or_replace_surface(box); + // Resize to the actual output in case we got a valid box. + if (surface_ != nullptr && box.width > 0 && box.height > 0) { + surface_->set_position(box.x, box.y); + surface_->set_size(box.width, box.height); + } + } + + // ---- data members --------------------------------------------------------- + + const kernel::Manifest manifest_{ + .id = "wallpaper", + .tier = kernel::Tier::standard, + .depends_on = {}, + }; + + Host* host_ = nullptr; + + // Config state. cfg_ is the live config, swapped on hot-reload. + std::optional<std::string> config_path_; + std::optional<std::string> effective_path_; + config::WallpaperConfig cfg_; + + // The wallpaper ui surface. Null on no-GL backend. Owned by this extension; + // destroyed BEFORE the subscriptions (sub_ members declared after it), which + // is exactly what we want: subscriptions release first so no event fires into + // a dead surface. Wait — actually in C++ members are destroyed in REVERSE + // declaration order (last declared = first destroyed), so we must declare + // subscriptions AFTER the surface so they are destroyed FIRST. That's the + // order below. + std::unique_ptr<kernel::UiSurface> surface_; + + // RAII handles — declared AFTER surface_ so they are DESTROYED FIRST at + // teardown. This ensures no late event fires into a dead surface or reads + // cfg_ after the extension's data is gone. (Reverse-declaration destruction, + // extension-agent.md.) + kernel::FileWatch config_watch_; + kernel::Subscription output_added_sub_; + kernel::Subscription output_removed_sub_; +}; + +} // namespace + +auto create(std::optional<std::string> config_path) -> std::unique_ptr<kernel::Extension> { + return std::make_unique<WallpaperExtension>(std::move(config_path)); +} + +} // namespace unbox::ext_wallpaper diff --git a/packages/ext-wallpaper/tests/test_config.cpp b/packages/ext-wallpaper/tests/test_config.cpp new file mode 100644 index 0000000..6ae38d9 --- /dev/null +++ b/packages/ext-wallpaper/tests/test_config.cpp @@ -0,0 +1,182 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include <doctest/doctest.h> + +#include "config.hpp" + +#include <string_view> + +// Pure-core tests for the [wallpaper] config loader (src/config.cpp). +// No kernel, no wlroots — just toml text in, WallpaperConfig out. Mirrors +// ext-window-field's config test posture: defaults on absence, per-key +// validation, warnings on bad values, never throws. + +namespace cfg = unbox::ext_wallpaper::config; +using cfg::FitMode; + +// ---- empty / missing --------------------------------------------------------- + +TEST_CASE("empty document yields defaults, no error") { + const cfg::LoadResult r = cfg::load_from_string(""); + CHECK_FALSE(r.parse_error); + CHECK(r.warnings.empty()); + CHECK(r.cfg.path.empty()); + CHECK(r.cfg.fit == FitMode::cover); + CHECK(r.cfg.color == "#000000"); +} + +TEST_CASE("absent [wallpaper] table yields defaults") { + const cfg::LoadResult r = cfg::load_from_string("[other]\nkey = 1\n"); + CHECK_FALSE(r.parse_error); + CHECK(r.warnings.empty()); + CHECK(r.cfg.fit == FitMode::cover); + CHECK(r.cfg.color == "#000000"); +} + +TEST_CASE("missing path key -> empty path (no image), no warning") { + const cfg::LoadResult r = cfg::load_from_string("[wallpaper]\nfit = \"cover\"\n"); + CHECK(r.cfg.path.empty()); + CHECK(r.warnings.empty()); +} + +TEST_CASE("explicit empty path is valid (no image)") { + const cfg::LoadResult r = cfg::load_from_string("[wallpaper]\npath = \"\"\n"); + CHECK_FALSE(r.parse_error); + CHECK(r.cfg.path.empty()); + // empty path is allowed — glue treats it as "no image" + CHECK(r.warnings.empty()); +} + +// ---- path -------------------------------------------------------------------- + +TEST_CASE("path parses as string") { + const cfg::LoadResult r = + cfg::load_from_string("[wallpaper]\npath = \"/home/me/wall.jpg\"\n"); + CHECK(r.cfg.path == "/home/me/wall.jpg"); + CHECK(r.warnings.empty()); +} + +TEST_CASE("non-string path warns and uses empty") { + const cfg::LoadResult r = cfg::load_from_string("[wallpaper]\npath = 42\n"); + CHECK(r.cfg.path.empty()); + CHECK(r.warnings.size() == 1); +} + +// ---- fit --------------------------------------------------------------------- + +TEST_CASE("every fit value parses correctly") { + CHECK(cfg::load_from_string("[wallpaper]\nfit = \"cover\"\n").cfg.fit == FitMode::cover); + CHECK(cfg::load_from_string("[wallpaper]\nfit = \"contain\"\n").cfg.fit == FitMode::contain); + CHECK(cfg::load_from_string("[wallpaper]\nfit = \"stretch\"\n").cfg.fit == FitMode::stretch); + CHECK(cfg::load_from_string("[wallpaper]\nfit = \"center\"\n").cfg.fit == FitMode::center); +} + +TEST_CASE("unknown fit falls back to cover with a warning") { + const cfg::LoadResult r = cfg::load_from_string("[wallpaper]\nfit = \"tile\"\n"); + CHECK_FALSE(r.parse_error); + CHECK(r.cfg.fit == FitMode::cover); + CHECK(r.warnings.size() == 1); +} + +TEST_CASE("non-string fit warns and keeps cover") { + const cfg::LoadResult r = cfg::load_from_string("[wallpaper]\nfit = 3\n"); + CHECK(r.cfg.fit == FitMode::cover); + CHECK(r.warnings.size() == 1); +} + +// ---- color ------------------------------------------------------------------- + +TEST_CASE("valid 6-digit hex color parses") { + const cfg::LoadResult r = cfg::load_from_string("[wallpaper]\ncolor = \"#1e1e2e\"\n"); + CHECK(r.cfg.color == "#1e1e2e"); + CHECK(r.warnings.empty()); +} + +TEST_CASE("valid 3-digit hex color parses") { + const cfg::LoadResult r = cfg::load_from_string("[wallpaper]\ncolor = \"#fff\"\n"); + CHECK(r.cfg.color == "#fff"); + CHECK(r.warnings.empty()); +} + +TEST_CASE("bad color (missing hash) warns and keeps default") { + const cfg::LoadResult r = cfg::load_from_string("[wallpaper]\ncolor = \"1e1e2e\"\n"); + CHECK(r.cfg.color == "#000000"); + CHECK(r.warnings.size() == 1); +} + +TEST_CASE("bad color (wrong length) warns and keeps default") { + const cfg::LoadResult r = cfg::load_from_string("[wallpaper]\ncolor = \"#12\"\n"); + CHECK(r.cfg.color == "#000000"); + CHECK(r.warnings.size() == 1); +} + +TEST_CASE("non-string color warns and keeps default") { + const cfg::LoadResult r = cfg::load_from_string("[wallpaper]\ncolor = 0\n"); + CHECK(r.cfg.color == "#000000"); + CHECK(r.warnings.size() == 1); +} + +// ---- parse error ------------------------------------------------------------- + +TEST_CASE("toml syntax error sets parse_error and keeps defaults") { + const cfg::LoadResult r = cfg::load_from_string("[wallpaper\nfit = "); + CHECK(r.parse_error); + CHECK(r.cfg.fit == FitMode::cover); + CHECK(r.cfg.color == "#000000"); + CHECK(r.warnings.size() == 1); +} + +// ---- [wallpaper] not a table ------------------------------------------------- + +TEST_CASE("[wallpaper] not a table warns") { + const cfg::LoadResult r = cfg::load_from_string("wallpaper = 5\n"); + CHECK_FALSE(r.parse_error); + CHECK(r.warnings.size() == 1); + CHECK(r.cfg.fit == FitMode::cover); +} + +// ---- rmlui_fit_keyword mapping ----------------------------------------------- + +TEST_CASE("rmlui_fit_keyword maps all FitMode values") { + CHECK(cfg::rmlui_fit_keyword(FitMode::cover) == "cover"); + CHECK(cfg::rmlui_fit_keyword(FitMode::contain) == "contain"); + CHECK(cfg::rmlui_fit_keyword(FitMode::stretch) == "fill"); + CHECK(cfg::rmlui_fit_keyword(FitMode::center) == "scale-none"); +} + +// ---- independent per-key validation ------------------------------------------ + +TEST_CASE("bad fit and bad color both warn independently") { + const cfg::LoadResult r = cfg::load_from_string( + "[wallpaper]\nfit = \"tile\"\ncolor = \"notacolor\"\n"); + CHECK_FALSE(r.parse_error); + CHECK(r.cfg.fit == FitMode::cover); + CHECK(r.cfg.color == "#000000"); + CHECK(r.warnings.size() == 2); +} + +// ---- default_image_path (pure path-join helper) ------------------------------ + +TEST_CASE("default_image_path joins asset root and relative segment") { + CHECK(cfg::default_image_path("/usr/share/unbox") == + "/usr/share/unbox/ext-wallpaper/default.jpg"); +} + +TEST_CASE("default_image_path strips a trailing slash from the root") { + CHECK(cfg::default_image_path("/usr/share/unbox/") == + "/usr/share/unbox/ext-wallpaper/default.jpg"); +} + +TEST_CASE("default_image_path strips multiple trailing slashes") { + CHECK(cfg::default_image_path("/opt/unbox///") == + "/opt/unbox/ext-wallpaper/default.jpg"); +} + +TEST_CASE("default_image_path with bare dot (process cwd fallback)") { + CHECK(cfg::default_image_path(".") == "./ext-wallpaper/default.jpg"); +} + +TEST_CASE("default_image_path with empty root gives a usable relative path") { + // An empty asset_root should not crash; it yields the relative segment. + const std::string p = cfg::default_image_path(""); + CHECK(p == "/ext-wallpaper/default.jpg"); +} diff --git a/packages/ext-wallpaper/tests/test_wallpaper.cpp b/packages/ext-wallpaper/tests/test_wallpaper.cpp new file mode 100644 index 0000000..fbf038b --- /dev/null +++ b/packages/ext-wallpaper/tests/test_wallpaper.cpp @@ -0,0 +1,22 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include <doctest/doctest.h> + +#include <unbox/ext-wallpaper/ext_wallpaper.hpp> +#include <unbox/kernel/extension.hpp> + +// Smoke test: the factory yields an extension with the correct manifest. +// No kernel running; this is cheap and ABI-surface only. + +TEST_CASE("ext-wallpaper: factory yields the wallpaper extension") { + auto ext = unbox::ext_wallpaper::create(); + REQUIRE(ext != nullptr); + CHECK(ext->manifest().id == "wallpaper"); + CHECK(ext->manifest().tier == unbox::kernel::Tier::standard); + CHECK(ext->manifest().depends_on.empty()); +} + +TEST_CASE("ext-wallpaper: factory accepts explicit config path") { + auto ext = unbox::ext_wallpaper::create("/tmp/nonexistent.toml"); + REQUIRE(ext != nullptr); + CHECK(ext->manifest().id == "wallpaper"); +} diff --git a/packages/host-bin/meson.build b/packages/host-bin/meson.build index 2ff8559..333a2fd 100644 --- a/packages/host-bin/meson.build +++ b/packages/host-bin/meson.build @@ -4,6 +4,6 @@ executable( 'unbox', 'src/main.cpp', - dependencies: [kernel_dep, ext_xdg_shell_dep, ext_layer_shell_dep, ext_keybindings_dep, ext_stage_dock_dep, ext_window_field_dep], + dependencies: [kernel_dep, ext_xdg_shell_dep, ext_layer_shell_dep, ext_keybindings_dep, ext_stage_dock_dep, ext_window_field_dep, ext_wallpaper_dep], install: true, ) diff --git a/packages/host-bin/src/main.cpp b/packages/host-bin/src/main.cpp index 6a00bb4..e5d3c47 100644 --- a/packages/host-bin/src/main.cpp +++ b/packages/host-bin/src/main.cpp @@ -3,6 +3,7 @@ #include <unbox/ext-keybindings/ext_keybindings.hpp> #include <unbox/ext-layer-shell/ext_layer_shell.hpp> #include <unbox/ext-stage-dock/ext_stage_dock.hpp> +#include <unbox/ext-wallpaper/ext_wallpaper.hpp> #include <unbox/ext-window-field/ext_window_field.hpp> #include <unbox/ext-xdg-shell/ext_xdg_shell.hpp> #include <unbox/kernel/kernel.hpp> @@ -72,6 +73,11 @@ auto main(int argc, char* argv[]) -> int { // dock of previews; tap a preview to restore. Standard tier, hidden until // it holds a minimized window (depends_on xdg-shell, topo-activated). server->install(unbox::ext_stage_dock::create()); + // The desktop wallpaper: a config-driven background image ([wallpaper] in + // unbox.toml), composited in the background scene layer below every window. + // Standard tier, always on; its surface is input-transparent so it never + // steals clicks. depends_on nothing (topo-activated). + server->install(unbox::ext_wallpaper::create(config_path)); // RML compositing (Phase 2, opt-in): the window field composites toplevels // as RCSS surface elements. depends_on "xdg-shell" (topologically // activated). When off, toplevels keep compositing through wlr_scene. @@ -62,3 +62,22 @@ action = "quit" [window-field] resize_mode = "settle" resize_debounce_ms = 100 + +# Wallpaper --------------------------------------------------------------------- +# The desktop background, composited below every window. Edits hot-reload live. +# +# path Absolute path to an image file (PNG, JPEG, BMP, GIF, TGA, …). Empty or +# omitted = no image, just the solid `color` fill below. +# fit How the image fills the screen: +# "cover" (default) crop to fill, preserves aspect ratio, no bars +# "contain" letterbox: whole image visible, preserves aspect ratio +# "stretch" distort to exactly fill the screen +# "center" natural pixel size, centred (no scaling) +# color Solid fill shown behind/around the image (and when `path` is empty). +# CSS hex, "#rrggbb" or "#rgb". Default "#000000". +# +# Omit this table for a plain black background. +[wallpaper] +path = "" +fit = "cover" +color = "#1e1e2e" |
