palette: better community palettes management

This commit is contained in:
Lemmy
2026-05-03 10:33:36 -04:00
parent d09de82c5c
commit 55c2753cbe
10 changed files with 215 additions and 39 deletions
+1 -1
View File
@@ -1091,7 +1091,7 @@
},
"community-palette": {
"label": "Community Palette",
"description": "Palette name to fetch from the community catalog"
"description": "Choose a palette from the community catalog"
},
"ui-scale": {
"label": "UI Scale",
+1
View File
@@ -589,6 +589,7 @@ _noctalia_sources = files(
'src/theme/custom_schemes.cpp',
'src/theme/builtin_palettes.cpp',
'src/theme/builtin_templates.cpp',
'src/theme/community_palettes.cpp',
'src/theme/community_templates.cpp',
'src/theme/fixed_palette.cpp',
'src/theme/template_apply_service.cpp',
+3
View File
@@ -310,6 +310,9 @@ void Application::initServices() {
m_configService.addReloadCallback(applyPasswordMaskStyle);
m_configService.addReloadCallback(
[this]() { m_httpClient.setOfflineMode(m_configService.config().shell.offlineMode); });
m_communityPaletteService.setReadyCallback([this]() { m_settingsWindow.onExternalOptionsChanged(); });
m_communityPaletteService.sync();
m_configService.addReloadCallback([this]() { m_communityPaletteService.sync(); });
m_communityTemplateService.setReadyCallback([this]() {
if (m_configService.config().theme.templates.enableCommunityTemplates) {
m_themeService.onConfigReload();
+2
View File
@@ -67,6 +67,7 @@
#include "system/telemetry_service.h"
#include "system/weather_poll_source.h"
#include "system/weather_service.h"
#include "theme/community_palettes.h"
#include "theme/community_templates.h"
#include "theme/template_apply_service.h"
#include "theme/theme_service.h"
@@ -118,6 +119,7 @@ private:
VirtualKeyboardService m_virtualKeyboardService;
ConfigService m_configService;
HttpClient m_httpClient;
noctalia::theme::CommunityPaletteService m_communityPaletteService{m_httpClient};
noctalia::theme::CommunityTemplateService m_communityTemplateService{m_httpClient};
noctalia::theme::ThemeService m_themeService{m_configService, m_httpClient};
noctalia::theme::TemplateApplyService m_templateApplyService{m_configService};
+5 -1
View File
@@ -272,9 +272,13 @@ namespace settings {
{"theme", "wallpaper_scheme"}, wallpaperSchemeSelect(cfg.theme.wallpaperScheme),
"wallpaper palette generator scheme material you m3 colors"));
} else if (cfg.theme.source == ThemeSource::Community) {
SettingControl communityPaletteControl = TextSetting{cfg.theme.communityPalette, "Oxocarbon"};
if (!env.communityPalettes.empty()) {
communityPaletteControl = SelectSetting{env.communityPalettes, cfg.theme.communityPalette};
}
entries.push_back(makeEntry("appearance", "theme", tr("settings.schema.appearance.community-palette.label"),
tr("settings.schema.appearance.community-palette.description"),
{"theme", "community_palette"}, TextSetting{cfg.theme.communityPalette, "Noctalia"},
{"theme", "community_palette"}, std::move(communityPaletteControl),
"community palette colors"));
}
entries.push_back(makeEntry("appearance", "interface", tr("settings.schema.appearance.ui-scale.label"),
+1
View File
@@ -97,6 +97,7 @@ namespace settings {
struct RegistryEnvironment {
bool niriBackdropSupported = false; // hide the [backdrop] section when false
std::vector<SelectOption> availableOutputs; // monitor selectors available on this machine
std::vector<SelectOption> communityPalettes;
std::vector<SelectOption> communityTemplates;
};
+4
View File
@@ -11,6 +11,7 @@
#include "shell/settings/settings_entity_editor.h"
#include "shell/settings/settings_registry.h"
#include "shell/settings/settings_sidebar.h"
#include "theme/community_palettes.h"
#include "theme/community_templates.h"
#include "ui/controls/box.h"
#include "ui/controls/button.h"
@@ -936,6 +937,9 @@ void SettingsWindow::buildScene(std::uint32_t width, std::uint32_t height) {
}
settings::RegistryEnvironment env;
env.niriBackdropSupported = (m_wayland != nullptr && compositors::isNiri());
for (const auto& paletteInfo : noctalia::theme::availableCommunityPalettes()) {
env.communityPalettes.push_back(settings::SelectOption{paletteInfo.name, paletteInfo.name});
}
for (const auto& t : noctalia::theme::CommunityTemplateService::availableTemplates()) {
env.communityTemplates.push_back(settings::SelectOption{t.id, t.displayName});
}
+156
View File
@@ -0,0 +1,156 @@
#include "theme/community_palettes.h"
#include "core/deferred_call.h"
#include "core/log.h"
#include "net/http_client.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <exception>
#include <filesystem>
#include <format>
#include <fstream>
#include <json.hpp>
#include <sstream>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>
namespace noctalia::theme {
namespace {
constexpr Logger kLog("community_palettes");
constexpr std::string_view kCatalogUrl = "https://api.noctalia.dev/palettes";
constexpr std::string_view kPaletteUrlBase = "https://api.noctalia.dev/palette";
std::filesystem::path catalogCachePath() { return communityPaletteCacheDir() / ".catalog" / "palettes.json"; }
std::string urlEncode(std::string_view text) {
std::string encoded;
encoded.reserve(text.size() * 3);
auto isUnreserved = [](unsigned char ch) {
return std::isalnum(ch) != 0 || ch == '-' || ch == '_' || ch == '.' || ch == '~';
};
for (char rawCh : text) {
const auto ch = static_cast<unsigned char>(rawCh);
if (isUnreserved(ch)) {
encoded.push_back(static_cast<char>(ch));
} else {
encoded += std::format("%{:02X}", static_cast<unsigned int>(ch));
}
}
return encoded;
}
std::string stringField(const nlohmann::json& obj, std::string_view key) {
auto it = obj.find(std::string(key));
if (it == obj.end() || !it->is_string()) {
return {};
}
return it->get<std::string>();
}
std::string paletteNameFromJson(const nlohmann::json& item) {
if (item.is_string()) {
return item.get<std::string>();
}
if (!item.is_object()) {
return {};
}
return stringField(item, "name");
}
std::vector<AvailablePalette> parseCatalogFile(const std::filesystem::path& path) {
std::ifstream in(path);
if (!in) {
return {};
}
try {
std::stringstream buf;
buf << in.rdbuf();
const auto root = nlohmann::json::parse(buf.str());
const nlohmann::json* entries = &root;
if (root.is_object()) {
if (auto it = root.find("palettes"); it != root.end()) {
entries = &*it;
}
}
if (!entries->is_array()) {
return {};
}
std::vector<AvailablePalette> out;
out.reserve(entries->size());
for (const auto& item : *entries) {
AvailablePalette palette;
palette.name = paletteNameFromJson(item);
if (!palette.name.empty()) {
out.push_back(std::move(palette));
}
}
std::sort(out.begin(), out.end(),
[](const AvailablePalette& a, const AvailablePalette& b) { return a.name < b.name; });
out.erase(std::unique(out.begin(), out.end(),
[](const AvailablePalette& a, const AvailablePalette& b) { return a.name == b.name; }),
out.end());
return out;
} catch (const std::exception& e) {
kLog.warn("failed to parse community palette catalog {}: {}", path.string(), e.what());
return {};
}
}
} // namespace
CommunityPaletteService::CommunityPaletteService(HttpClient& httpClient) : m_httpClient(httpClient) {}
void CommunityPaletteService::setReadyCallback(ReadyCallback callback) { m_readyCallback = std::move(callback); }
void CommunityPaletteService::sync() {
const std::uint64_t generation = ++m_generation;
const std::filesystem::path path = catalogCachePath();
std::error_code ec;
std::filesystem::create_directories(path.parent_path(), ec);
m_httpClient.download(kCatalogUrl, path, [this, generation, path](bool success) {
if (generation != m_generation) {
return;
}
if (!success) {
kLog.warn("failed to refresh community palette catalog; using cached metadata when available");
return;
}
if (parseCatalogFile(path).empty()) {
kLog.warn("community palette catalog downloaded but contained no palettes");
}
if (m_readyCallback) {
DeferredCall::callLater([callback = m_readyCallback]() { callback(); });
}
});
}
std::vector<AvailablePalette> availableCommunityPalettes() { return parseCatalogFile(catalogCachePath()); }
std::filesystem::path communityPaletteCacheDir() {
if (const char* xdg = std::getenv("XDG_CACHE_HOME"); xdg != nullptr && xdg[0] != '\0') {
return std::filesystem::path(xdg) / "noctalia" / "community-palettes";
}
if (const char* home = std::getenv("HOME"); home != nullptr && home[0] != '\0') {
return std::filesystem::path(home) / ".cache" / "noctalia" / "community-palettes";
}
return std::filesystem::path("/tmp") / "noctalia" / "community-palettes";
}
std::filesystem::path communityPaletteCachePath(std::string_view name) {
return communityPaletteCacheDir() / (urlEncode(name) + ".json");
}
std::string communityPaletteDownloadUrl(std::string_view name) {
return std::string(kPaletteUrlBase) + "/" + urlEncode(name);
}
} // namespace noctalia::theme
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <cstdint>
#include <filesystem>
#include <functional>
#include <string>
#include <string_view>
#include <vector>
class HttpClient;
namespace noctalia::theme {
struct AvailablePalette {
std::string name;
};
class CommunityPaletteService {
public:
using ReadyCallback = std::function<void()>;
explicit CommunityPaletteService(HttpClient& httpClient);
void setReadyCallback(ReadyCallback callback);
void sync();
private:
HttpClient& m_httpClient;
ReadyCallback m_readyCallback;
std::uint64_t m_generation = 0;
};
[[nodiscard]] std::vector<AvailablePalette> availableCommunityPalettes();
[[nodiscard]] std::filesystem::path communityPaletteCacheDir();
[[nodiscard]] std::filesystem::path communityPaletteCachePath(std::string_view name);
[[nodiscard]] std::string communityPaletteDownloadUrl(std::string_view name);
} // namespace noctalia::theme
+4 -37
View File
@@ -6,6 +6,7 @@
#include "ipc/ipc_service.h"
#include "net/http_client.h"
#include "theme/builtin_palettes.h"
#include "theme/community_palettes.h"
#include "theme/fixed_palette.h"
#include "theme/image_loader.h"
#include "theme/palette_generator.h"
@@ -13,10 +14,8 @@
#include <cctype>
#include <chrono>
#include <cstdlib>
#include <exception>
#include <filesystem>
#include <format>
#include <fstream>
#include <json.hpp>
#include <sstream>
@@ -85,37 +84,6 @@ namespace noctalia::theme {
};
}
std::string urlEncode(std::string_view text) {
std::string encoded;
encoded.reserve(text.size() * 3);
auto isUnreserved = [](unsigned char ch) {
return std::isalnum(ch) != 0 || ch == '-' || ch == '_' || ch == '.' || ch == '~';
};
for (char rawCh : text) {
const auto ch = static_cast<unsigned char>(rawCh);
if (isUnreserved(ch)) {
encoded.push_back(static_cast<char>(ch));
} else {
encoded += std::format("%{:02X}", static_cast<unsigned int>(ch));
}
}
return encoded;
}
std::filesystem::path communityPaletteCacheDir() {
if (const char* xdg = std::getenv("XDG_CACHE_HOME"); xdg != nullptr && xdg[0] != '\0') {
return std::filesystem::path(xdg) / "noctalia" / "community-palettes";
}
if (const char* home = std::getenv("HOME"); home != nullptr && home[0] != '\0') {
return std::filesystem::path(home) / ".cache" / "noctalia" / "community-palettes";
}
return std::filesystem::path("/tmp") / "noctalia" / "community-palettes";
}
std::filesystem::path communityPaletteCachePath(std::string_view encodedName) {
return communityPaletteCacheDir() / (std::string(encodedName) + ".json");
}
// Reads a color key from a JSON object, looking first for the `m`-prefixed form
// (e.g. `mPrimary`) and falling back to the unprefixed name. Returns fallback
// (transparent black) if the key is missing or the value is not a hex string.
@@ -334,11 +302,10 @@ namespace noctalia::theme {
return;
}
m_inflightCommunityName = name;
const std::string encoded = urlEncode(name);
const auto cachePath = communityPaletteCachePath(encoded);
const auto cachePath = communityPaletteCachePath(name);
std::error_code ec;
std::filesystem::create_directories(cachePath.parent_path(), ec);
const std::string url = "https://api.noctalia.dev/palette/" + encoded;
const std::string url = communityPaletteDownloadUrl(name);
kLog.info("fetching community palette '{}' from {}", name, url);
m_httpClient.download(url, cachePath, [this, name, cachePath](bool success) {
if (m_inflightCommunityName == name) {
@@ -365,7 +332,7 @@ namespace noctalia::theme {
if (cfg.source == ThemeSource::Wallpaper) {
resolved = resolveWallpaper(cfg, m_config.getDefaultWallpaperPath());
} else if (cfg.source == ThemeSource::Community && !cfg.communityPalette.empty()) {
const auto cachePath = communityPaletteCachePath(urlEncode(cfg.communityPalette));
const auto cachePath = communityPaletteCachePath(cfg.communityPalette);
if (std::filesystem::exists(cachePath)) {
if (auto parsed = parseCommunityPaletteJson(cachePath)) {
resolved = makeResolvedFromParsed(*parsed, cfg);