feat(wallpaper): add automated wallpaper change settings

This commit is contained in:
Lysec
2026-04-24 14:44:30 +02:00
parent 11ffc35029
commit c72be240b7
8 changed files with 248 additions and 18 deletions
+1
View File
@@ -8,6 +8,7 @@ A ready-to-use starting config with all defaults is at [`example.toml`](example.
Notification daemon toggle: use `[notification].enable_daemon` (documented in [`config/services.md`](config/services.md)).
Weather location visibility toggle: use `[shell].show_location` (documented in [`config/shell.md`](config/shell.md)).
Wallpaper automation: use `[wallpaper.automation]` (documented in [`config/wallpaper.md`](config/wallpaper.md)).
---
+11 -1
View File
@@ -12,10 +12,16 @@ edge_smoothness = 0.3 # 0.0 1.0
# Directory browsed by the wallpaper picker panel
directory = "/home/user/Wallpapers"
# Optional per-mode directories (parsed but not yet consumed by the renderer)
# Optional per-mode directories
directory_light = "/home/user/Wallpapers/Light"
directory_dark = "/home/user/Wallpapers/Dark"
[wallpaper.automation]
enabled = false
interval_minutes = 30 # 0 = disable automation
order = "random" # random | alphabetical
recursive = true # scan subdirectories when selecting random wallpapers
# Per-monitor overrides — same match rules as bar monitor overrides
[wallpaper.monitor.DP-2]
enabled = false
@@ -26,6 +32,10 @@ directory_dark = "/home/user/Wallpapers/Vertical/Dark"
The wallpaper picker panel lists images in `directory` as a grid of thumbnails. Selecting a monitor in the panel toolbar switches to that monitor's override directory (falling back to the base `directory`). Clicking a tile writes the path to `state.toml` and applies it immediately. Picking a wallpaper while **ALL** is selected applies it to every connected output.
When automation is enabled, Noctalia picks one image from `directory` on the configured interval and applies it to all connected outputs in sync.
`order = "random"` chooses a random image each cycle.
`order = "alphabetical"` sorts paths case-insensitively and advances to the next image each cycle (wrapping at the end).
---
# Overview
+8
View File
@@ -27,6 +27,14 @@ transition = ["fade", "wipe", "disc", "stripes", "zoom", "honeycomb"]
transition_duration = 1500 # milliseconds
edge_smoothness = 0.3
directory = "~/Pictures/Wallpapers"
directory_light = "" # optional day-mode directory
directory_dark = "" # optional night-mode directory
[wallpaper.automation]
enabled = false
interval_minutes = 0 # 0 = disabled
order = "random" # random | alphabetical
recursive = true # include subdirectories when picking random wallpapers
# ── Theme ─────────────────────────────────────────────────────────────────────
+1
View File
@@ -821,6 +821,7 @@ void Application::initUi() {
});
m_timeService.setTickSecondCallback([this]() {
m_wallpaper.onSecondTick();
if (m_lockScreen.isActive()) {
if (formatLocalTime("{:%S}") == "00") {
m_lockScreen.onSecondTick();
+21
View File
@@ -1427,6 +1427,27 @@ void ConfigService::parseTable(const toml::table& tbl) {
wp.directoryLight = *v;
if (auto v = (*wpTbl)["directory_dark"].value<std::string>())
wp.directoryDark = *v;
if (auto* automationTbl = (*wpTbl)["automation"].as_table()) {
if (auto v = (*automationTbl)["enabled"].value<bool>()) {
wp.automation.enabled = *v;
}
if (auto v = (*automationTbl)["interval_minutes"].value<int64_t>()) {
wp.automation.intervalMinutes = std::clamp(static_cast<std::int32_t>(*v), 0, 1440);
}
if (auto v = (*automationTbl)["order"].value<std::string>()) {
const std::string order = StringUtils::toLower(StringUtils::trim(*v));
if (order == "random") {
wp.automation.order = WallpaperAutomationConfig::Order::Random;
} else if (order == "alphabetical") {
wp.automation.order = WallpaperAutomationConfig::Order::Alphabetical;
} else {
kLog.warn("unknown wallpaper automation order \"{}\" (expected: random|alphabetical)", *v);
}
}
if (auto v = (*automationTbl)["recursive"].value<bool>()) {
wp.automation.recursive = *v;
}
}
if (auto* monTblMap = (*wpTbl)["monitor"].as_table()) {
for (const auto& [monName, monNode] : *monTblMap) {
+13
View File
@@ -171,6 +171,18 @@ struct WallpaperMonitorOverride {
std::optional<std::string> directoryDark;
};
struct WallpaperAutomationConfig {
enum class Order : std::uint8_t {
Random = 0,
Alphabetical = 1,
};
bool enabled = false;
std::int32_t intervalMinutes = 0; // 0 = disabled
Order order = Order::Random;
bool recursive = true;
};
struct WallpaperConfig {
bool enabled = true;
WallpaperFillMode fillMode = WallpaperFillMode::Crop;
@@ -182,6 +194,7 @@ struct WallpaperConfig {
std::string directory;
std::string directoryLight;
std::string directoryDark;
WallpaperAutomationConfig automation;
std::vector<WallpaperMonitorOverride> monitorOverrides;
};
+187 -17
View File
@@ -9,7 +9,13 @@
#include "wayland/wayland_connection.h"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <filesystem>
#include <string_view>
#include <system_error>
#include <vector>
using Random::randomFloat;
@@ -47,6 +53,116 @@ namespace {
return params;
}
bool hasImageExtension(const std::filesystem::path& path) {
std::string ext = path.extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" || ext == ".bmp" || ext == ".gif";
}
void collectWallpaperCandidates(const std::filesystem::path& directory, bool recursive,
std::vector<std::string>& out) {
out.clear();
std::error_code ec;
if (!std::filesystem::exists(directory, ec) || !std::filesystem::is_directory(directory, ec)) {
return;
}
if (recursive) {
for (auto it = std::filesystem::recursive_directory_iterator(
directory, std::filesystem::directory_options::skip_permission_denied, ec);
!ec && it != std::filesystem::end(it); it.increment(ec)) {
if (ec) {
break;
}
std::error_code typeEc;
if (!it->is_regular_file(typeEc) || typeEc) {
continue;
}
if (hasImageExtension(it->path())) {
out.push_back(it->path().string());
}
}
return;
}
for (const auto& entry : std::filesystem::directory_iterator(
directory, std::filesystem::directory_options::skip_permission_denied, ec)) {
if (ec) {
break;
}
std::error_code typeEc;
if (!entry.is_regular_file(typeEc) || typeEc) {
continue;
}
if (hasImageExtension(entry.path())) {
out.push_back(entry.path().string());
}
}
}
const WallpaperMonitorOverride* findWallpaperMonitorOverride(const WallpaperConfig& config,
const WaylandOutput& output) {
for (const auto& ovr : config.monitorOverrides) {
if (outputMatchesSelector(ovr.match, output)) {
return &ovr;
}
}
return nullptr;
}
std::string pickRandomWallpaperPath(const std::vector<std::string>& candidates, const std::string& currentPath) {
if (candidates.empty()) {
return {};
}
if (candidates.size() == 1) {
return candidates.front();
}
const std::size_t start = std::min<std::size_t>(
static_cast<std::size_t>(std::floor(randomFloat(0.0f, static_cast<float>(candidates.size())))),
candidates.size() - 1);
for (std::size_t i = 0; i < candidates.size(); ++i) {
const std::string& candidate = candidates[(start + i) % candidates.size()];
if (candidate != currentPath) {
return candidate;
}
}
return candidates.front();
}
bool lessCaseInsensitive(std::string_view a, std::string_view b) {
const std::size_t minLen = std::min(a.size(), b.size());
for (std::size_t i = 0; i < minLen; ++i) {
const auto ac = static_cast<unsigned char>(a[i]);
const auto bc = static_cast<unsigned char>(b[i]);
const auto alc = static_cast<unsigned char>(std::tolower(ac));
const auto blc = static_cast<unsigned char>(std::tolower(bc));
if (alc != blc) {
return alc < blc;
}
}
return a.size() < b.size();
}
std::string pickAlphabeticalWallpaperPath(std::vector<std::string> candidates, const std::string& currentPath) {
if (candidates.empty()) {
return {};
}
std::sort(candidates.begin(), candidates.end(),
[](const std::string& a, const std::string& b) { return lessCaseInsensitive(a, b); });
if (candidates.size() == 1) {
return candidates.front();
}
const auto it = std::find(candidates.begin(), candidates.end(), currentPath);
if (it == candidates.end()) {
return candidates.front();
}
const std::size_t idx = static_cast<std::size_t>(std::distance(candidates.begin(), it));
return candidates[(idx + 1) % candidates.size()];
}
constexpr Logger kLog("wallpaper");
} // namespace
@@ -74,6 +190,7 @@ bool Wallpaper::initialize(WaylandConnection& wayland, ConfigService* config, Gl
m_config->setWallpaperChangeCallback([this]() { onStateChange(); });
m_config->addReloadCallback([this]() { reload(); });
resetAutomationState();
syncInstances();
return true;
}
@@ -82,6 +199,7 @@ void Wallpaper::reload() {
kLog.info("reloading config");
const bool nowEnabled = m_config->config().wallpaper.enabled;
resetAutomationState();
if (!nowEnabled) {
// Wallpaper disabled — full teardown
@@ -144,6 +262,20 @@ void Wallpaper::onStateChange() {
}
}
void Wallpaper::onSecondTick() {
if (m_config == nullptr || !m_config->config().wallpaper.enabled) {
return;
}
using namespace std::chrono;
const auto minuteStamp = duration_cast<minutes>(system_clock::now().time_since_epoch()).count();
if (minuteStamp == m_lastAutomationMinuteStamp) {
return;
}
m_lastAutomationMinuteStamp = minuteStamp;
runAutomation(minuteStamp);
}
void Wallpaper::syncInstances() {
const auto& outputs = m_wayland->outputs();
@@ -164,15 +296,11 @@ void Wallpaper::syncInstances() {
}
// Check if a monitor override now disables this output
for (const auto& ovr : m_config->config().wallpaper.monitorOverrides) {
const auto& match = ovr.match;
bool hit = (!output->connectorName.empty() && match == output->connectorName) ||
(!output->description.empty() && output->description.find(match) != std::string::npos);
if (hit && ovr.enabled && !*ovr.enabled) {
kLog.info("removing instance for {} — disabled by monitor override", output->connectorName);
releaseInstanceTextures(*inst);
return true;
}
if (const auto* ovr = findWallpaperMonitorOverride(m_config->config().wallpaper, *output);
ovr != nullptr && ovr->enabled && !*ovr->enabled) {
kLog.info("removing instance for {} — disabled by monitor override", output->connectorName);
releaseInstanceTextures(*inst);
return true;
}
return false;
@@ -191,14 +319,9 @@ void Wallpaper::syncInstances() {
}
bool enabled = true;
for (const auto& ovr : m_config->config().wallpaper.monitorOverrides) {
const auto& match = ovr.match;
bool hit = (!output.connectorName.empty() && match == output.connectorName) ||
(!output.description.empty() && output.description.find(match) != std::string::npos);
if (hit && ovr.enabled) {
enabled = *ovr.enabled;
break;
}
if (const auto* ovr = findWallpaperMonitorOverride(m_config->config().wallpaper, output);
ovr != nullptr && ovr->enabled) {
enabled = *ovr->enabled;
}
if (!enabled) {
kLog.info("skipping {} ({}) — disabled by monitor override", output.connectorName, output.description);
@@ -209,6 +332,53 @@ void Wallpaper::syncInstances() {
}
}
void Wallpaper::resetAutomationState() {
m_lastAutomationMinuteStamp = -1;
m_lastAutomationSwitchMinute = -1;
}
void Wallpaper::runAutomation(std::int64_t minuteStamp) {
const auto& wallpaper = m_config->config().wallpaper;
const auto& automation = wallpaper.automation;
if (!automation.enabled || automation.intervalMinutes <= 0 || m_instances.empty()) {
return;
}
if (m_lastAutomationSwitchMinute >= 0 &&
(minuteStamp - m_lastAutomationSwitchMinute) < static_cast<std::int64_t>(automation.intervalMinutes)) {
return;
}
std::vector<std::string> candidates;
collectWallpaperCandidates(wallpaper.directory, automation.recursive, candidates);
if (candidates.empty()) {
return;
}
const std::string currentDefault = m_config->getDefaultWallpaperPath();
const std::string picked = automation.order == WallpaperAutomationConfig::Order::Alphabetical
? pickAlphabeticalWallpaperPath(candidates, currentDefault)
: pickRandomWallpaperPath(candidates, currentDefault);
if (picked.empty()) {
return;
}
m_lastAutomationSwitchMinute = minuteStamp;
if (picked == currentDefault) {
return;
}
ConfigService::WallpaperBatch batch(*m_config);
for (const auto& inst : m_instances) {
if (inst->connectorName.empty()) {
continue;
}
m_config->setWallpaperPath(inst->connectorName, picked);
}
m_config->setWallpaperPath(std::nullopt, picked);
kLog.info("automation set all outputs → {}", picked);
}
void Wallpaper::createInstance(const WaylandOutput& output) {
auto wallpaperPath = m_config->getWallpaperPath(output.connectorName);
kLog.info("creating on {} ({}), path={}", output.connectorName, output.description, wallpaperPath);
+6
View File
@@ -2,6 +2,7 @@
#include "shell/wallpaper/wallpaper_instance.h"
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
@@ -21,10 +22,13 @@ public:
SharedTextureCache* textureCache);
void onOutputChange();
void onStateChange();
void onSecondTick();
private:
void reload();
void syncInstances();
void resetAutomationState();
void runAutomation(std::int64_t minuteStamp);
void createInstance(const WaylandOutput& output);
void loadWallpaper(WallpaperInstance& instance, const std::string& path);
void startTransition(WallpaperInstance& instance);
@@ -35,5 +39,7 @@ private:
ConfigService* m_config = nullptr;
GlSharedContext* m_sharedGl = nullptr;
SharedTextureCache* m_textureCache = nullptr;
std::int64_t m_lastAutomationMinuteStamp = -1;
std::int64_t m_lastAutomationSwitchMinute = -1;
std::vector<std::unique_ptr<WallpaperInstance>> m_instances;
};