summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-15 20:04:15 +0900
committerAdam Malczewski <[email protected]>2026-06-15 20:04:15 +0900
commit310ef9b53f0ab8396e15e6f9e077df5399508539 (patch)
treea20c412c63199abb697054d0850a471ea6c37417
parent143cb713b685a9089e85c90a932fb79efa606dc6 (diff)
downloadunbox-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.jpgbin0 -> 7503872 bytes
-rw-r--r--meson.build1
-rw-r--r--packages/ext-wallpaper/include/unbox/ext-wallpaper/ext_wallpaper.hpp46
-rw-r--r--packages/ext-wallpaper/meson.build73
-rw-r--r--packages/ext-wallpaper/src/config.cpp140
-rw-r--r--packages/ext-wallpaper/src/config.hpp77
-rw-r--r--packages/ext-wallpaper/src/extension.cpp378
-rw-r--r--packages/ext-wallpaper/tests/test_config.cpp182
-rw-r--r--packages/ext-wallpaper/tests/test_wallpaper.cpp22
-rw-r--r--packages/host-bin/meson.build2
-rw-r--r--packages/host-bin/src/main.cpp6
-rw-r--r--unbox.toml19
12 files changed, 945 insertions, 1 deletions
diff --git a/assets/ext-wallpaper/default.jpg b/assets/ext-wallpaper/default.jpg
new file mode 100644
index 0000000..485d429
--- /dev/null
+++ b/assets/ext-wallpaper/default.jpg
Binary files differ
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.
diff --git a/unbox.toml b/unbox.toml
index 90cf86a..7662f33 100644
--- a/unbox.toml
+++ b/unbox.toml
@@ -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"