mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
feat(wallpaper): add automated wallpaper change settings
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user