feat(notifications): persist history, filters, icons, and compact image assets

This commit is contained in:
Ly-sec
2026-05-03 11:52:50 +02:00
parent c8c5830507
commit f3e2102513
12 changed files with 1067 additions and 16 deletions
+8
View File
@@ -329,6 +329,14 @@
"notifications": {
"empty-title": "No notifications",
"empty-body": "Recent notifications will show here.",
"filter-empty-title": "Nothing here",
"filter-empty-body": "No notifications match this filter.",
"filter": {
"all": "All",
"today": "Today",
"yesterday": "Yesterday",
"older": "Older"
},
"untitled": "Untitled notification",
"status": {
"active": "Active",
+1
View File
@@ -368,6 +368,7 @@ _noctalia_sources = files(
'src/net/http_client.cpp',
'src/net/uri.cpp',
'src/notification/notification_manager.cpp',
'src/notification/notification_history_store.cpp',
'src/pipewire/pipewire_service.cpp',
'src/pipewire/pipewire_spectrum.cpp',
'src/pipewire/sound_player.cpp',
+2
View File
@@ -112,6 +112,7 @@ namespace {
} // namespace
Application::Application() : m_weatherService(m_configService, m_httpClient) {
m_notificationManager.loadPersistedHistory();
notify::setInstance(&m_notificationManager);
LockScreen::setInstance(&m_lockScreen);
@@ -150,6 +151,7 @@ Application::Application() : m_weatherService(m_configService, m_httpClient) {
}
Application::~Application() {
m_notificationManager.flushPersistedHistory();
m_wayland.setClipboardService(nullptr);
m_wayland.setVirtualKeyboardService(nullptr);
LockScreen::setInstance(nullptr);
+6
View File
@@ -26,6 +26,8 @@ enum class NotificationOrigin : uint8_t {
using Clock = std::chrono::steady_clock;
using TimePoint = Clock::time_point;
using WallClock = std::chrono::system_clock;
using WallTimePoint = WallClock::time_point;
struct NotificationImageData {
std::int32_t width = 0;
@@ -54,4 +56,8 @@ struct Notification {
std::optional<std::string> desktopEntry;
TimePoint receivedTime; // add/replace time used for duplicate burst suppression
std::optional<TimePoint> expiryTime; // absent = never expires
/// Wall-clock receive time (history UI, persistence). Optional for pre-migration / corrupt rows.
std::optional<WallTimePoint> receivedWallClock;
/// Wall-clock expiry when timeout-based; mirrors expiryTime where applicable.
std::optional<WallTimePoint> expiryWallClock;
};
@@ -0,0 +1,713 @@
#include "notification/notification_history_store.h"
#include "core/log.h"
#include "notification/notification_manager.h"
#include "render/core/image_decoder.h"
#include "util/file_utils.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <json.hpp>
#include <optional>
#include <sstream>
#include <string_view>
#include <unordered_set>
#include <vector>
#include <webp/encode.h>
namespace {
constexpr Logger kLog("notification-history");
constexpr std::string_view kOriginExternal = "external";
constexpr std::string_view kOriginInternal = "internal";
constexpr std::string_view kUrgencyLow = "low";
constexpr std::string_view kUrgencyNormal = "normal";
constexpr std::string_view kUrgencyCritical = "critical";
constexpr std::string_view kCloseExpired = "expired";
constexpr std::string_view kCloseDismissed = "dismissed";
constexpr std::string_view kCloseByCall = "closed_by_call";
std::optional<std::chrono::system_clock::time_point> millisToWall(int64_t ms) {
if (ms <= 0) {
return std::nullopt;
}
return std::chrono::system_clock::time_point{std::chrono::milliseconds{ms}};
}
int64_t wallToMillis(const std::optional<std::chrono::system_clock::time_point>& tp) {
if (!tp.has_value()) {
return 0;
}
return std::chrono::duration_cast<std::chrono::milliseconds>(tp->time_since_epoch()).count();
}
std::string_view urgencyStr(Urgency u) noexcept {
switch (u) {
case Urgency::Low:
return kUrgencyLow;
case Urgency::Normal:
return kUrgencyNormal;
case Urgency::Critical:
return kUrgencyCritical;
}
return kUrgencyNormal;
}
Urgency urgencyFrom(std::string_view s) noexcept {
if (s == kUrgencyLow) {
return Urgency::Low;
}
if (s == kUrgencyCritical) {
return Urgency::Critical;
}
return Urgency::Normal;
}
std::string_view originStr(NotificationOrigin o) noexcept {
return o == NotificationOrigin::Internal ? kOriginInternal : kOriginExternal;
}
NotificationOrigin originFrom(std::string_view s) noexcept {
return s == kOriginInternal ? NotificationOrigin::Internal : NotificationOrigin::External;
}
std::optional<CloseReason> closeReasonFrom(std::string_view s) noexcept {
if (s == kCloseExpired) {
return CloseReason::Expired;
}
if (s == kCloseDismissed) {
return CloseReason::Dismissed;
}
if (s == kCloseByCall) {
return CloseReason::ClosedByCall;
}
return std::nullopt;
}
std::string_view closeReasonStr(CloseReason r) noexcept {
switch (r) {
case CloseReason::Expired:
return kCloseExpired;
case CloseReason::Dismissed:
return kCloseDismissed;
case CloseReason::ClosedByCall:
return kCloseByCall;
}
return kCloseByCall;
}
static const char kBase64Chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string base64Encode(const std::vector<std::uint8_t>& data) {
std::string out;
out.reserve(((data.size() + 2) / 3) * 4);
for (std::size_t i = 0; i < data.size(); i += 3) {
const std::size_t n = std::min<std::size_t>(3, data.size() - i);
std::uint32_t chunk = 0;
for (std::size_t j = 0; j < n; ++j) {
chunk |= static_cast<std::uint32_t>(data[i + j]) << static_cast<unsigned>((16 - static_cast<int>(j * 8)));
}
out.push_back(kBase64Chars[(chunk >> 18) & 63]);
out.push_back(kBase64Chars[(chunk >> 12) & 63]);
out.push_back(n > 1 ? kBase64Chars[(chunk >> 6) & 63] : '=');
out.push_back(n > 2 ? kBase64Chars[chunk & 63] : '=');
}
return out;
}
std::vector<std::uint8_t> base64Decode(std::string_view in) {
std::vector<int> decodeTable(256, -1);
for (int b = 0; b < 64; ++b) {
decodeTable[static_cast<unsigned char>(kBase64Chars[b])] = b;
}
std::vector<std::uint8_t> out;
out.reserve(in.size() * 3 / 4);
int val = 0;
int valb = -8;
for (unsigned char c : in) {
if (c == '=') {
break;
}
const int d = decodeTable[c];
if (d < 0) {
continue;
}
val = (val << 6) + d;
valb += 6;
if (valb >= 0) {
out.push_back(static_cast<std::uint8_t>((val >> valb) & 0xFF));
valb -= 8;
}
}
return out;
}
constexpr std::string_view kAssetsDirName = "notification_history_assets";
/// History list only needs small previews; keeps WebP sidecars tiny.
constexpr int kMaxPersistImageSide = 96;
constexpr float kPersistWebPQuality = 65.0f;
std::filesystem::path assetsDirectoryForJson(const std::filesystem::path& jsonFilePath) {
return jsonFilePath.parent_path() / kAssetsDirName;
}
/// 32 lowercase hex chars — content-addressed asset names (`i_<hex>.webp`).
std::string hashBytesToHex32(const std::uint8_t* p, std::size_t n) {
std::uint64_t h0 = 14695981039346656037ULL;
std::uint64_t h1 = 13166748625691186689ULL;
for (std::size_t i = 0; i < n; ++i) {
h0 ^= p[i];
h0 *= 1099511628211ULL;
h1 ^= static_cast<std::uint64_t>(p[i]) << ((i % 8) * 8);
h1 *= 11400714819323198485ULL;
}
h0 ^= n;
h1 ^= n * 0x9e3779b97f4a7c15ULL;
char buf[33];
std::snprintf(buf, sizeof(buf), "%016llx%016llx", static_cast<unsigned long long>(h0),
static_cast<unsigned long long>(h1));
return std::string(buf);
}
std::string contentAddressedWebpName(const std::uint8_t* rgba, std::size_t rgbaBytes) {
return std::string("i_") + hashBytesToHex32(rgba, rgbaBytes) + ".webp";
}
std::string contentAddressedRgbaName(const std::uint8_t* bytes, std::size_t byteCount) {
return std::string("i_") + hashBytesToHex32(bytes, byteCount) + ".rgba";
}
bool writeRawRgbaBlob(const std::filesystem::path& assetsDir, const std::string& baseFileName,
const std::vector<std::uint8_t>& bytes) {
std::error_code ec;
std::filesystem::create_directories(assetsDir, ec);
const auto path = assetsDir / baseFileName;
if (!bytes.empty() && std::filesystem::exists(path, ec) && std::filesystem::file_size(path, ec) == bytes.size()) {
return true;
}
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f) {
return false;
}
if (!bytes.empty()) {
f.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
}
return static_cast<bool>(f);
}
bool writeWebpBlobIfAbsent(const std::filesystem::path& path, const std::uint8_t* encoded, std::size_t encodedSize) {
std::error_code ec;
if (encoded == nullptr || encodedSize == 0) {
return false;
}
if (std::filesystem::exists(path, ec)) {
const auto sz = std::filesystem::file_size(path, ec);
if (!ec && sz == static_cast<std::uint64_t>(encodedSize)) {
return true;
}
}
std::filesystem::create_directories(path.parent_path(), ec);
std::ofstream wf(path, std::ios::binary | std::ios::trunc);
if (!wf) {
return false;
}
wf.write(reinterpret_cast<const char*>(encoded), static_cast<std::streamsize>(encodedSize));
return static_cast<bool>(wf);
}
/// Icon preset + slow method trades CPU once-per-image for smaller files than WebPEncodeRGBA alone.
std::optional<std::vector<std::uint8_t>> encodeWebpForHistory(const std::uint8_t* rgba, int w, int h, int stride) {
WebPConfig config;
if (!WebPConfigPreset(&config, WEBP_PRESET_ICON, kPersistWebPQuality)) {
return std::nullopt;
}
config.method = 6;
config.alpha_quality = 70;
if (!WebPValidateConfig(&config)) {
return std::nullopt;
}
WebPPicture picture;
if (!WebPPictureInit(&picture)) {
return std::nullopt;
}
picture.width = w;
picture.height = h;
if (!WebPPictureImportRGBA(&picture, rgba, stride)) {
WebPPictureFree(&picture);
return std::nullopt;
}
WebPMemoryWriter writer;
WebPMemoryWriterInit(&writer);
picture.writer = WebPMemoryWrite;
picture.custom_ptr = &writer;
if (!WebPEncode(&config, &picture)) {
WebPPictureFree(&picture);
WebPMemoryWriterClear(&writer);
return std::nullopt;
}
WebPPictureFree(&picture);
std::vector<std::uint8_t> out;
if (writer.size > 0 && writer.mem != nullptr) {
out.assign(writer.mem, writer.mem + writer.size);
}
WebPMemoryWriterClear(&writer);
if (out.empty()) {
return std::nullopt;
}
return out;
}
bool packContiguousRgba(const NotificationImageData& img, std::vector<std::uint8_t>& outRgba, int& outW, int& outH) {
if (img.width <= 0 || img.height <= 0 || img.data.empty()) {
return false;
}
if (img.bitsPerSample != 8 || (img.channels != 3 && img.channels != 4)) {
return false;
}
outW = img.width;
outH = img.height;
const int c = img.channels;
const int rs = (img.rowStride > 0) ? img.rowStride : (outW * c);
outRgba.resize(static_cast<std::size_t>(outW) * outH * 4);
for (int y = 0; y < outH; ++y) {
const std::uint8_t* row = img.data.data() + static_cast<std::size_t>(y) * rs;
std::uint8_t* dst = outRgba.data() + static_cast<std::size_t>(y) * outW * 4;
if (c == 4) {
std::memcpy(dst, row, static_cast<std::size_t>(outW) * 4);
} else {
for (int x = 0; x < outW; ++x) {
dst[x * 4 + 0] = row[x * 3 + 0];
dst[x * 4 + 1] = row[x * 3 + 1];
dst[x * 4 + 2] = row[x * 3 + 2];
dst[x * 4 + 3] = 255;
}
}
}
return true;
}
void downscaleRgbaIfNeeded(std::vector<std::uint8_t>& rgba, int& w, int& h, int maxSide) {
if (w <= maxSide && h <= maxSide) {
return;
}
const float scale = std::min(static_cast<float>(maxSide) / static_cast<float>(w),
static_cast<float>(maxSide) / static_cast<float>(h));
const int nw = std::max(1, static_cast<int>(std::lround(static_cast<float>(w) * scale)));
const int nh = std::max(1, static_cast<int>(std::lround(static_cast<float>(h) * scale)));
std::vector<std::uint8_t> dst(static_cast<std::size_t>(nw) * nh * 4);
for (int y = 0; y < nh; ++y) {
const int sy = y * h / nh;
for (int x = 0; x < nw; ++x) {
const int sx = x * w / nw;
const std::uint8_t* srcPx = rgba.data() + (static_cast<std::size_t>(sy) * w + sx) * 4;
std::uint8_t* dstPx = dst.data() + (static_cast<std::size_t>(y) * nw + x) * 4;
std::memcpy(dstPx, srcPx, 4);
}
}
rgba = std::move(dst);
w = nw;
h = nh;
}
std::optional<NotificationImageData> imageFromJson(const nlohmann::json& j,
const std::filesystem::path& jsonFilePath) {
if (!j.is_object()) {
return std::nullopt;
}
NotificationImageData img;
img.width = j.value("width", 0);
img.height = j.value("height", 0);
img.rowStride = j.value("row_stride", 0);
img.hasAlpha = j.value("has_alpha", true);
img.bitsPerSample = j.value("bits_per_sample", 8);
img.channels = j.value("channels", 4);
const auto fileOnly = j.value("image_file", std::string());
if (!fileOnly.empty()) {
const auto blobPath = assetsDirectoryForJson(jsonFilePath) / fileOnly;
img.data = FileUtils::readBinaryFile(blobPath.string());
if (!img.data.empty()) {
std::string decErr;
if (auto decoded = decodeRasterImage(img.data.data(), img.data.size(), &decErr)) {
img.width = decoded->width;
img.height = decoded->height;
img.rowStride = img.width * 4;
img.channels = 4;
img.hasAlpha = true;
img.bitsPerSample = 8;
img.data = std::move(decoded->pixels);
return img;
}
// Legacy sidecar: raw RGBA bytes (not a supported container format).
if (img.width > 0 && img.height > 0 && img.channels >= 3) {
const std::size_t expected =
static_cast<std::size_t>(img.width) * img.height * static_cast<std::size_t>(img.channels);
if (img.data.size() >= expected) {
return img;
}
}
kLog.warn("could not decode notification image blob {} ({})", blobPath.string(), decErr);
} else if (img.width > 0 && img.height > 0) {
kLog.warn("missing or empty image blob {}", blobPath.string());
}
return img;
}
const auto b64 = j.value("data_b64", std::string());
if (!b64.empty()) {
img.data = base64Decode(b64);
return img;
}
return img;
}
nlohmann::json imageToJson(const NotificationImageData& img, const std::filesystem::path& jsonFilePath,
uint32_t notificationId) {
nlohmann::json j;
j["has_alpha"] = img.hasAlpha;
j["bits_per_sample"] = img.bitsPerSample;
j["channels"] = img.channels;
if (img.data.empty() || img.width <= 0 || img.height <= 0) {
j["width"] = img.width;
j["height"] = img.height;
j["row_stride"] = img.rowStride;
return j;
}
std::vector<std::uint8_t> rgba;
int w = 0;
int h = 0;
if (!packContiguousRgba(img, rgba, w, h)) {
kLog.warn("could not pack notification image pixels for id {}", notificationId);
j["width"] = img.width;
j["height"] = img.height;
j["row_stride"] = img.rowStride;
j["data_b64"] = base64Encode(img.data);
return j;
}
downscaleRgbaIfNeeded(rgba, w, h, kMaxPersistImageSide);
const auto assetsDir = assetsDirectoryForJson(jsonFilePath);
std::error_code ecMk;
std::filesystem::create_directories(assetsDir, ecMk);
const std::string webpBase = contentAddressedWebpName(rgba.data(), rgba.size());
const auto webpPath = assetsDir / webpBase;
// Same normalized pixels → same filename: skip WebP encode and skip rewriting the file.
if (std::filesystem::exists(webpPath, ecMk)) {
j["width"] = w;
j["height"] = h;
j["row_stride"] = w * 4;
j["has_alpha"] = true;
j["bits_per_sample"] = 8;
j["channels"] = 4;
j["image_file"] = webpBase;
return j;
}
std::vector<std::uint8_t> encodedBuf;
if (auto advanced = encodeWebpForHistory(rgba.data(), w, h, w * 4); advanced.has_value()) {
encodedBuf = std::move(*advanced);
} else {
std::uint8_t* encoded = nullptr;
const std::size_t encodedSize = WebPEncodeRGBA(rgba.data(), w, h, w * 4, kPersistWebPQuality, &encoded);
if (encoded != nullptr && encodedSize > 0) {
encodedBuf.assign(encoded, encoded + encodedSize);
WebPFree(encoded);
}
}
if (!encodedBuf.empty() && writeWebpBlobIfAbsent(webpPath, encodedBuf.data(), encodedBuf.size())) {
j["width"] = w;
j["height"] = h;
j["row_stride"] = w * 4;
j["has_alpha"] = true;
j["bits_per_sample"] = 8;
j["channels"] = 4;
j["image_file"] = webpBase;
return j;
}
if (encodedBuf.empty()) {
kLog.warn("WebP encode failed for notification image id {}", notificationId);
} else {
kLog.warn("failed to write WebP notification image for id {}", notificationId);
}
const std::string rawBase = contentAddressedRgbaName(img.data.data(), img.data.size());
if (writeRawRgbaBlob(assetsDir, rawBase, img.data)) {
j["width"] = img.width;
j["height"] = img.height;
j["row_stride"] = img.rowStride;
j["image_file"] = rawBase;
return j;
}
kLog.warn("failed to write notification image blob for id {}", notificationId);
j["width"] = img.width;
j["height"] = img.height;
j["row_stride"] = img.rowStride;
j["data_b64"] = base64Encode(img.data);
return j;
}
nlohmann::json notificationToJson(const Notification& n, const std::filesystem::path& jsonFilePath) {
nlohmann::json j;
j["id"] = n.id;
j["origin"] = std::string(originStr(n.origin));
j["app_name"] = n.appName;
j["summary"] = n.summary;
j["body"] = n.body;
j["timeout"] = n.timeout;
j["urgency"] = std::string(urgencyStr(n.urgency));
j["actions"] = n.actions;
if (n.icon.has_value()) {
j["icon"] = *n.icon;
} else {
j["icon"] = nullptr;
}
if (n.imageData.has_value()) {
j["image_data"] = imageToJson(*n.imageData, jsonFilePath, n.id);
} else {
j["image_data"] = nullptr;
}
if (n.category.has_value()) {
j["category"] = *n.category;
} else {
j["category"] = nullptr;
}
if (n.desktopEntry.has_value()) {
j["desktop_entry"] = *n.desktopEntry;
} else {
j["desktop_entry"] = nullptr;
}
j["received_wall_ms"] = wallToMillis(n.receivedWallClock);
j["expiry_wall_ms"] = wallToMillis(n.expiryWallClock);
return j;
}
Notification notificationFromJson(const nlohmann::json& j, const std::filesystem::path& jsonFilePath) {
Notification n{};
n.id = j.value("id", 0U);
n.origin = originFrom(j.value("origin", std::string(kOriginExternal)));
n.appName = j.value("app_name", std::string());
n.summary = j.value("summary", std::string());
n.body = j.value("body", std::string());
n.timeout = j.value("timeout", 0);
n.urgency = urgencyFrom(j.value("urgency", std::string(kUrgencyNormal)));
if (j.contains("actions") && j["actions"].is_array()) {
for (const auto& a : j["actions"]) {
if (a.is_string()) {
n.actions.push_back(a.get<std::string>());
}
}
}
if (j.contains("icon") && !j["icon"].is_null()) {
n.icon = j["icon"].get<std::string>();
}
if (j.contains("image_data") && j["image_data"].is_object()) {
n.imageData = imageFromJson(j["image_data"], jsonFilePath);
}
if (j.contains("category") && !j["category"].is_null()) {
n.category = j["category"].get<std::string>();
}
if (j.contains("desktop_entry") && !j["desktop_entry"].is_null()) {
n.desktopEntry = j["desktop_entry"].get<std::string>();
}
const int64_t rw = j.value("received_wall_ms", int64_t{0});
if (rw > 0) {
n.receivedWallClock = millisToWall(rw);
}
const int64_t ew = j.value("expiry_wall_ms", int64_t{0});
if (ew > 0) {
n.expiryWallClock = millisToWall(ew);
}
const auto steadyNow = Clock::now();
n.receivedTime = steadyNow;
n.expiryTime.reset();
return n;
}
void collectReferencedImageFiles(const nlohmann::json& root, std::unordered_set<std::string>& out) {
const auto entries = root.find("entries");
if (entries == root.end() || !entries->is_array()) {
return;
}
for (const auto& item : *entries) {
if (!item.is_object() || !item.contains("notification")) {
continue;
}
const auto& n = item["notification"];
if (!n.contains("image_data") || !n["image_data"].is_object()) {
continue;
}
const auto f = n["image_data"].value("image_file", std::string());
if (!f.empty()) {
out.insert(f);
}
}
}
void pruneOrphanImageBlobs(const std::filesystem::path& jsonFilePath,
const std::unordered_set<std::string>& keepFiles) {
const auto assetsDir = assetsDirectoryForJson(jsonFilePath);
std::error_code ec;
if (!std::filesystem::is_directory(assetsDir, ec)) {
return;
}
for (const auto& ent : std::filesystem::directory_iterator(assetsDir, ec)) {
if (ec || !ent.is_regular_file()) {
continue;
}
const std::string name = ent.path().filename().string();
const auto dot = name.rfind('.');
if (dot == std::string::npos) {
continue;
}
const std::string ext = name.substr(dot);
if (ext != ".rgba" && ext != ".webp") {
continue;
}
const bool legacyPerId = name.size() >= 7 && name[0] == 'n' && name[1] == '_';
const bool contentAddressed = name.size() >= 7 && name[0] == 'i' && name[1] == '_';
if (!legacyPerId && !contentAddressed) {
continue;
}
if (keepFiles.find(name) == keepFiles.end()) {
std::filesystem::remove(ent.path(), ec);
}
}
}
} // namespace
bool loadNotificationHistoryFromFile(const std::filesystem::path& path, std::deque<NotificationHistoryEntry>& out,
std::uint32_t& outNextId, std::uint64_t& outChangeSerial) {
out.clear();
outNextId = 1;
outChangeSerial = 0;
std::error_code ec;
if (!std::filesystem::exists(path, ec)) {
return true;
}
std::ifstream in(path, std::ios::binary);
if (!in) {
kLog.warn("could not open notification history {}", path.string());
return false;
}
std::stringstream buffer;
buffer << in.rdbuf();
nlohmann::json root;
try {
root = nlohmann::json::parse(buffer.str());
} catch (const std::exception& e) {
kLog.warn("notification history parse failed: {}", e.what());
return false;
}
if (!root.is_object()) {
return false;
}
outNextId = root.value("next_id", 1U);
outChangeSerial = root.value("change_serial", std::uint64_t{0});
const auto entries = root.find("entries");
if (entries == root.end() || !entries->is_array()) {
return true;
}
std::uint32_t maxId = 0;
std::uint64_t maxSerial = 0;
for (const auto& item : *entries) {
if (!item.is_object()) {
continue;
}
NotificationHistoryEntry he;
he.notification = notificationFromJson(item.at("notification"), path);
he.active = item.value("active", false);
if (item.contains("close_reason") && !item["close_reason"].is_null()) {
const auto crs = item["close_reason"].get<std::string>();
he.closeReason = closeReasonFrom(crs);
}
he.eventSerial = item.value("event_serial", std::uint64_t{0});
maxId = std::max(maxId, he.notification.id);
maxSerial = std::max(maxSerial, he.eventSerial);
out.push_back(std::move(he));
}
outNextId = std::max(outNextId, maxId + 1);
outChangeSerial = std::max(outChangeSerial, maxSerial);
constexpr std::size_t kMaxHistoryEntries = 100;
while (out.size() > kMaxHistoryEntries) {
out.pop_front();
}
return true;
}
bool saveNotificationHistoryToFile(const std::filesystem::path& path,
const std::deque<NotificationHistoryEntry>& entries, std::uint32_t nextId,
std::uint64_t changeSerial) {
nlohmann::json root;
root["version"] = 2;
root["next_id"] = nextId;
root["change_serial"] = changeSerial;
auto& arr = root["entries"] = nlohmann::json::array();
for (const auto& he : entries) {
nlohmann::json je;
je["notification"] = notificationToJson(he.notification, path);
je["active"] = he.active;
if (he.closeReason.has_value()) {
je["close_reason"] = std::string(closeReasonStr(*he.closeReason));
} else {
je["close_reason"] = nullptr;
}
je["event_serial"] = he.eventSerial;
arr.push_back(std::move(je));
}
const std::string tmpPath = path.string() + ".tmp";
std::ofstream out(tmpPath, std::ios::binary | std::ios::trunc);
if (!out) {
kLog.warn("could not write notification history tmp {}", tmpPath);
return false;
}
out << root.dump(2);
out.close();
std::error_code ec;
std::filesystem::rename(tmpPath, path, ec);
if (ec) {
kLog.warn("could not rename notification history file: {}", ec.message());
return false;
}
std::unordered_set<std::string> keepImageFiles;
collectReferencedImageFiles(root, keepImageFiles);
pruneOrphanImageBlobs(path, keepImageFiles);
return true;
}
@@ -0,0 +1,14 @@
#pragma once
#include <cstdint>
#include <deque>
#include <filesystem>
struct NotificationHistoryEntry;
bool loadNotificationHistoryFromFile(const std::filesystem::path& path, std::deque<NotificationHistoryEntry>& out,
std::uint32_t& outNextId, std::uint64_t& outChangeSerial);
bool saveNotificationHistoryToFile(const std::filesystem::path& path,
const std::deque<NotificationHistoryEntry>& entries, std::uint32_t nextId,
std::uint64_t changeSerial);
+69 -1
View File
@@ -1,8 +1,12 @@
#include "notification_manager.h"
#include "core/deferred_call.h"
#include "core/log.h"
#include "notification/notification_history_store.h"
#include "pipewire/sound_player.h"
#include "util/file_utils.h"
#include <filesystem>
#include <string_view>
namespace {
@@ -39,6 +43,13 @@ namespace {
return std::nullopt; // 0 = persistent, -1 = server default (treat as persistent for now)
}
std::optional<WallTimePoint> schedule_expiry_wall(WallTimePoint wallNow, int32_t timeout_ms) noexcept {
if (timeout_ms > 0) {
return wallNow + std::chrono::milliseconds(timeout_ms);
}
return std::nullopt;
}
bool has_same_content(const Notification& notification, const std::string& appName, const std::string& summary,
const std::string& body) {
return notification.appName == appName && notification.summary == summary && notification.body == body;
@@ -72,6 +83,7 @@ void NotificationManager::upsertHistory(const Notification& notification, bool a
}
rebuildHistoryIndex();
schedulePersistHistory();
}
int NotificationManager::addEventCallback(EventCallback callback) {
@@ -92,6 +104,7 @@ uint32_t NotificationManager::addOrReplace(uint32_t replaces_id, std::string app
std::optional<std::string> category,
std::optional<std::string> desktop_entry) {
const auto now = Clock::now();
const auto wallNow = WallClock::now();
auto log_notification = [](const Notification& n, std::string_view action) {
kLog.debug("notification {} #{} origin={} from=\"{}\" urgency={} summary=\"{}\" body=\"{}\" timeout={}ms", action,
n.id, origin_str(n.origin), n.appName, urgency_str(n.urgency), n.summary, n.body, n.timeout);
@@ -119,6 +132,8 @@ uint32_t NotificationManager::addOrReplace(uint32_t replaces_id, std::string app
n.desktopEntry = std::move(desktop_entry);
n.receivedTime = now;
n.expiryTime = schedule_expiry(now, timeout);
n.receivedWallClock = wallNow;
n.expiryWallClock = schedule_expiry_wall(wallNow, timeout);
log_notification(n, "updated");
upsertHistory(n, true, std::nullopt);
@@ -158,6 +173,8 @@ uint32_t NotificationManager::addOrReplace(uint32_t replaces_id, std::string app
.desktopEntry = std::move(desktop_entry),
.receivedTime = now,
.expiryTime = schedule_expiry(now, timeout),
.receivedWallClock = wallNow,
.expiryWallClock = schedule_expiry_wall(wallNow, timeout),
});
m_idToIndex.emplace(id, m_notifications.size() - 1);
@@ -261,6 +278,7 @@ void NotificationManager::removeHistoryEntry(uint32_t id) {
m_history.erase(m_history.begin() + static_cast<std::ptrdiff_t>(it->second));
++m_changeSerial;
rebuildHistoryIndex();
schedulePersistHistory();
}
void NotificationManager::clearHistory() {
@@ -272,6 +290,7 @@ void NotificationManager::clearHistory() {
m_historyIndex.clear();
++m_changeSerial;
markNotificationHistorySeen();
schedulePersistHistory();
}
std::vector<uint32_t> NotificationManager::expiredIds() const {
@@ -312,6 +331,7 @@ void NotificationManager::pauseExpiry(uint32_t id) {
return;
}
m_notifications[it->second].expiryTime.reset();
m_notifications[it->second].expiryWallClock.reset();
}
void NotificationManager::resumeExpiry(uint32_t id, int32_t remainingMs) {
@@ -321,9 +341,13 @@ void NotificationManager::resumeExpiry(uint32_t id, int32_t remainingMs) {
}
if (remainingMs <= 0) {
m_notifications[it->second].expiryTime = Clock::now();
m_notifications[it->second].expiryWallClock = WallClock::now();
return;
}
m_notifications[it->second].expiryTime = Clock::now() + std::chrono::milliseconds(remainingMs);
const auto steadyResume = Clock::now();
const auto wallResume = WallClock::now();
m_notifications[it->second].expiryTime = steadyResume + std::chrono::milliseconds(remainingMs);
m_notifications[it->second].expiryWallClock = wallResume + std::chrono::milliseconds(remainingMs);
}
void NotificationManager::setDoNotDisturb(bool enabled) {
@@ -358,3 +382,47 @@ void NotificationManager::markNotificationHistorySeen() {
m_stateCallback();
}
}
void NotificationManager::schedulePersistHistory() {
if (m_persistScheduled) {
return;
}
m_persistScheduled = true;
DeferredCall::callLater([this]() {
m_persistScheduled = false;
persistHistoryToDisk();
});
}
void NotificationManager::persistHistoryToDisk() {
const std::string dir = FileUtils::stateDir();
if (dir.empty()) {
return;
}
std::filesystem::path path(dir);
std::error_code ec;
std::filesystem::create_directories(path, ec);
path /= "notification_history.json";
(void)saveNotificationHistoryToFile(path, m_history, m_nextId, m_changeSerial);
}
void NotificationManager::loadPersistedHistory() {
const std::string dir = FileUtils::stateDir();
if (dir.empty()) {
return;
}
std::filesystem::path path(dir);
path /= "notification_history.json";
std::uint32_t nextId = m_nextId;
std::uint64_t serial = m_changeSerial;
std::deque<NotificationHistoryEntry> loaded;
if (!loadNotificationHistoryFromFile(path, loaded, nextId, serial)) {
return;
}
m_history = std::move(loaded);
m_nextId = nextId;
m_changeSerial = serial;
rebuildHistoryIndex();
}
void NotificationManager::flushPersistedHistory() { persistHistoryToDisk(); }
+9
View File
@@ -91,9 +91,18 @@ public:
[[nodiscard]] bool hasUnreadNotificationHistory() const noexcept;
void markNotificationHistorySeen();
/// Loads persisted history from disk (call once at startup before emitting events).
void loadPersistedHistory();
/// Writes pending history to disk immediately (e.g. shutdown).
void flushPersistedHistory();
private:
void upsertHistory(const Notification& notification, bool active, std::optional<CloseReason> closeReason);
void rebuildHistoryIndex();
void schedulePersistHistory();
void persistHistoryToDisk();
bool m_persistScheduled = false;
std::deque<Notification> m_notifications;
std::unordered_map<uint32_t, size_t> m_idToIndex;
+220 -15
View File
@@ -2,26 +2,78 @@
#include "core/ui_phase.h"
#include "i18n/i18n.h"
#include "net/uri.h"
#include "notification/notification.h"
#include "notification/notification_manager.h"
#include "render/core/renderer.h"
#include "render/core/texture_manager.h"
#include "render/scene/node.h"
#include "shell/panel/panel_manager.h"
#include "time/time_format.h"
#include "ui/controls/button.h"
#include "ui/controls/flex.h"
#include "ui/controls/glyph.h"
#include "ui/controls/image.h"
#include "ui/controls/label.h"
#include "ui/controls/scroll_view.h"
#include "ui/controls/segmented.h"
#include "ui/palette.h"
#include <chrono>
#include <cmath>
#include <ctime>
#include <filesystem>
#include <functional>
#include <memory>
#include <string_view>
#include <unistd.h>
#include <vector>
using namespace control_center;
namespace {
constexpr float kHistoryIconSize = 36.0f;
constexpr float kHistoryIconRadius = 8.0f;
constexpr float kHistoryIconGlyphSize = 22.0f;
constexpr float kNotificationActionButtonSize = Style::controlHeightSm;
std::filesystem::path remoteNotificationIconCachePath(std::string_view url) {
return std::filesystem::path("/tmp") / "noctalia-notification-icons" /
(std::to_string(std::hash<std::string_view>{}(url)) + ".img");
}
std::string normalizeLocalIconPath(std::string_view iconValue) { return uri::normalizeFileUrl(iconValue); }
std::string resolveHistoryIconPath(const Notification& n, IconResolver& resolver) {
if (!n.icon.has_value() || n.icon->empty()) {
return {};
}
const std::string& iconValue = *n.icon;
if (uri::isRemoteUrl(iconValue)) {
const auto cached = remoteNotificationIconCachePath(iconValue);
std::error_code ec;
if (std::filesystem::exists(cached, ec) && std::filesystem::file_size(cached, ec) > 0) {
return cached.string();
}
return {};
}
const std::string localPath = normalizeLocalIconPath(iconValue);
if (!localPath.empty() && localPath.front() == '/') {
if (access(localPath.c_str(), R_OK) == 0) {
return localPath;
}
return {};
}
if (localPath.empty()) {
return {};
}
const std::string& resolved = resolver.resolve(localPath);
return resolved.empty() ? std::string() : resolved;
}
constexpr int kSummaryMaxLines = 2;
constexpr int kBodyMaxLines = 3;
constexpr int kExpandedMaxLines = 500;
@@ -56,6 +108,45 @@ namespace {
void applyNotificationCardStyle(Flex& card, float scale) { applySectionCardStyle(card, scale); }
std::string relativeMetaLine(const Notification& n) {
if (n.receivedWallClock.has_value()) {
return formatTimeAgo(*n.receivedWallClock);
}
return formatElapsedSince(n.receivedTime);
}
bool matchesHistoryFilter(const NotificationHistoryEntry& e, std::size_t filterIndex) {
if (filterIndex == 0) {
return true;
}
if (!e.notification.receivedWallClock.has_value()) {
return false;
}
const std::time_t entryT = WallClock::to_time_t(*e.notification.receivedWallClock);
const std::time_t nowT = WallClock::to_time_t(WallClock::now());
std::tm entryL{};
std::tm nowL{};
localtime_r(&entryT, &entryL);
localtime_r(&nowT, &nowL);
const bool isToday = entryL.tm_year == nowL.tm_year && entryL.tm_yday == nowL.tm_yday;
std::tm yRef = nowL;
yRef.tm_hour = 12;
yRef.tm_min = 0;
yRef.tm_sec = 0;
yRef.tm_mday -= 1;
mktime(&yRef);
const bool isYesterday = entryL.tm_year == yRef.tm_year && entryL.tm_yday == yRef.tm_yday;
if (filterIndex == 1) {
return isToday;
}
if (filterIndex == 2) {
return isYesterday;
}
// Older
return !isToday && !isYesterday;
}
bool canExpandText(Renderer& renderer, std::string_view text, float fontSize, bool bold, float maxWidth,
int collapsedMaxLines) {
if (text.empty()) {
@@ -81,6 +172,23 @@ std::unique_ptr<Flex> NotificationsTab::create() {
tab->setGap(Style::spaceSm * scale);
m_root = tab.get();
auto filter = std::make_unique<Segmented>();
filter->setScale(scale);
filter->setFontSize(Style::fontSizeCaption * scale);
filter->addOption(i18n::tr("control-center.notifications.filter.all"));
filter->addOption(i18n::tr("control-center.notifications.filter.today"));
filter->addOption(i18n::tr("control-center.notifications.filter.yesterday"));
filter->addOption(i18n::tr("control-center.notifications.filter.older"));
filter->setEqualSegmentWidths(true);
filter->setSelectedIndex(m_filterIndex);
filter->setOnChange([this](std::size_t idx) {
m_filterIndex = idx;
m_lastSerial = 0;
PanelManager::instance().refresh();
});
m_filter = filter.get();
tab->addChild(std::move(filter));
auto scroll = std::make_unique<ScrollView>();
scroll->setScrollbarVisible(true);
scroll->setViewportPaddingH(0.0f);
@@ -120,7 +228,7 @@ std::unique_ptr<Flex> NotificationsTab::createHeaderActions() {
}
void NotificationsTab::doLayout(Renderer& renderer, float contentWidth, float bodyHeight) {
if (m_root == nullptr || m_scroll == nullptr) {
if (m_root == nullptr || m_scroll == nullptr || m_filter == nullptr) {
return;
}
@@ -157,6 +265,7 @@ void NotificationsTab::onClose() {
m_root = nullptr;
m_scroll = nullptr;
m_list = nullptr;
m_filter = nullptr;
m_clearAllButton = nullptr;
m_expandedIds.clear();
m_lastSerial = 0;
@@ -220,7 +329,8 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
const std::int64_t relativeSlot =
std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count() /
15;
if (serial == m_lastSerial && std::abs(width - m_lastWidth) < 0.5f && relativeSlot == m_lastRelativeTimeSlot) {
if (serial == m_lastSerial && std::abs(width - m_lastWidth) < 0.5f && relativeSlot == m_lastRelativeTimeSlot &&
m_filterIndex == m_lastRebuildFilterIndex) {
return;
}
@@ -261,25 +371,68 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
m_lastSerial = serial;
m_lastWidth = width;
m_lastRelativeTimeSlot = relativeSlot;
m_lastRebuildFilterIndex = m_filterIndex;
return;
}
std::vector<const NotificationHistoryEntry*> filtered;
filtered.reserve(m_notifications->history().size());
for (auto it = m_notifications->history().rbegin(); it != m_notifications->history().rend(); ++it) {
const std::string summaryText =
it->notification.summary.empty() ? i18n::tr("control-center.notifications.untitled") : it->notification.summary;
const std::string& bodyText = it->notification.body;
if (matchesHistoryFilter(*it, m_filterIndex)) {
filtered.push_back(&*it);
}
}
if (filtered.empty()) {
auto empty = std::make_unique<Flex>();
applyNotificationCardStyle(*empty, scale);
empty->setAlign(FlexAlign::Center);
empty->setGap(Style::spaceSm * scale);
empty->setPadding(Style::spaceLg * scale, Style::spaceMd * scale);
empty->setMinWidth(cardWidth);
auto title = std::make_unique<Label>();
title->setText(i18n::tr("control-center.notifications.filter-empty-title"));
title->setBold(true);
title->setFontSize(Style::fontSizeBody * scale);
title->setColor(colorSpecFromRole(ColorRole::OnSurface));
empty->addChild(std::move(title));
auto body = std::make_unique<Label>();
body->setText(i18n::tr("control-center.notifications.filter-empty-body"));
body->setCaptionStyle();
body->setFontSize(Style::fontSizeCaption * scale);
body->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
empty->addChild(std::move(body));
m_list->addChild(std::move(empty));
m_lastSerial = serial;
m_lastWidth = width;
m_lastRelativeTimeSlot = relativeSlot;
m_lastRebuildFilterIndex = m_filterIndex;
return;
}
for (const NotificationHistoryEntry* entry : filtered) {
const std::string summaryText = entry->notification.summary.empty()
? i18n::tr("control-center.notifications.untitled")
: entry->notification.summary;
const std::string& bodyText = entry->notification.body;
const bool summaryExpandable =
canExpandText(renderer, summaryText, Style::fontSizeBody * scale, true, cardTextWidth, kSummaryMaxLines);
const bool bodyExpandable =
canExpandText(renderer, bodyText, Style::fontSizeBody * scale, false, cardTextWidth, kBodyMaxLines);
const bool canExpand = summaryExpandable || bodyExpandable;
const bool expanded = canExpand && m_expandedIds.contains(it->notification.id);
const bool expanded = canExpand && m_expandedIds.contains(entry->notification.id);
if (!canExpand) {
m_expandedIds.erase(it->notification.id);
m_expandedIds.erase(entry->notification.id);
}
const float iconPx = kHistoryIconSize * scale;
const float iconColumn = iconPx + Style::spaceSm * scale;
const float headerActionsWidth = actionButtonSize + (canExpand ? (actionButtonsGap + actionButtonSize) : 0.0f);
const float metaTextWidth = std::max(0.0f, cardTextWidth - headerActionsWidth - Style::spaceSm * scale);
const float leftClusterWidth = cardTextWidth - headerActionsWidth;
const float metaTextWidth = std::max(0.0f, leftClusterWidth - iconColumn);
auto card = std::make_unique<Flex>();
applyNotificationCardStyle(*card, scale);
@@ -291,20 +444,71 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
header->setJustify(FlexJustify::SpaceBetween);
header->setGap(Style::spaceSm * scale);
auto leftCluster = std::make_unique<Flex>();
leftCluster->setDirection(FlexDirection::Horizontal);
leftCluster->setAlign(FlexAlign::Center);
leftCluster->setGap(Style::spaceSm * scale);
leftCluster->setFlexGrow(1.0f);
auto iconSlot = std::make_unique<Node>();
iconSlot->setSize(iconPx, iconPx);
bool iconAssigned = false;
const std::string iconPath = resolveHistoryIconPath(entry->notification, m_iconResolver);
if (!iconPath.empty()) {
auto appIcon = std::make_unique<Image>();
appIcon->setSize(iconPx, iconPx);
appIcon->setPosition(0.0f, 0.0f);
appIcon->setRadius(kHistoryIconRadius * scale);
appIcon->setFit(ImageFit::Cover);
if (appIcon->setSourceFile(renderer, iconPath, static_cast<int>(std::round(iconPx)))) {
iconSlot->addChild(std::move(appIcon));
iconAssigned = true;
}
} else if (entry->notification.imageData.has_value()) {
const auto& image = *entry->notification.imageData;
if (image.width > 0 && image.height > 0 && !image.data.empty()) {
auto appIcon = std::make_unique<Image>();
appIcon->setSize(iconPx, iconPx);
appIcon->setPosition(0.0f, 0.0f);
appIcon->setRadius(kHistoryIconRadius * scale);
appIcon->setFit(ImageFit::Cover);
const bool validImageMetadata = image.bitsPerSample == 8 && ((image.channels == 4 && image.hasAlpha) ||
(image.channels == 3 && !image.hasAlpha));
const PixmapFormat format = image.channels == 3 ? PixmapFormat::RGB : PixmapFormat::RGBA;
if (validImageMetadata && appIcon->setSourceRaw(renderer, image.data.data(), image.data.size(), image.width,
image.height, image.rowStride, format, true)) {
iconSlot->addChild(std::move(appIcon));
iconAssigned = true;
}
}
}
if (!iconAssigned) {
auto fallback = std::make_unique<Glyph>();
fallback->setGlyph("bell");
fallback->setGlyphSize(kHistoryIconGlyphSize * scale);
fallback->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
fallback->measure(renderer);
fallback->setPosition(std::round((iconPx - fallback->width()) * 0.5f),
std::round((iconPx - fallback->height()) * 0.5f));
iconSlot->addChild(std::move(fallback));
}
leftCluster->addChild(std::move(iconSlot));
auto meta = std::make_unique<Label>();
std::string metaLine = it->notification.appName + "" + formatElapsedSince(it->notification.receivedTime);
if (!it->active) {
std::string metaLine = entry->notification.appName + "" + relativeMetaLine(entry->notification);
if (!entry->active) {
metaLine += "";
metaLine += statusText(*it);
metaLine += statusText(*entry);
}
meta->setText(std::move(metaLine));
meta->setCaptionStyle();
meta->setFontSize(Style::fontSizeCaption * scale);
meta->setColor(colorSpecFromRole(statusColorRole(*it)));
meta->setColor(colorSpecFromRole(statusColorRole(*entry)));
meta->setMaxWidth(metaTextWidth);
meta->setFlexGrow(1.0f);
meta->measure(renderer);
header->addChild(std::move(meta));
leftCluster->addChild(std::move(meta));
header->addChild(std::move(leftCluster));
auto headerActions = std::make_unique<Flex>();
headerActions->setDirection(FlexDirection::Horizontal);
@@ -320,7 +524,7 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
expand->setMinHeight(actionButtonSize);
expand->setPadding(Style::spaceXs * scale);
expand->setRadius(Style::radiusMd * scale);
expand->setOnClick([this, id = it->notification.id]() { toggleNotificationExpanded(id); });
expand->setOnClick([this, id = entry->notification.id]() { toggleNotificationExpanded(id); });
headerActions->addChild(std::move(expand));
}
@@ -333,7 +537,7 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
dismiss->setPadding(Style::spaceXs * scale);
dismiss->setRadius(Style::radiusMd * scale);
dismiss->setOnClick(
[this, id = it->notification.id, active = it->active]() { removeNotificationEntry(id, active); });
[this, id = entry->notification.id, active = entry->active]() { removeNotificationEntry(id, active); });
headerActions->addChild(std::move(dismiss));
header->addChild(std::move(headerActions));
card->addChild(std::move(header));
@@ -364,4 +568,5 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
m_lastSerial = serial;
m_lastWidth = width;
m_lastRelativeTimeSlot = relativeSlot;
m_lastRebuildFilterIndex = m_filterIndex;
}
@@ -1,6 +1,7 @@
#pragma once
#include "shell/control_center/tab.h"
#include "system/icon_resolver.h"
#include <cstdint>
#include <unordered_set>
@@ -8,6 +9,7 @@
class NotificationManager;
class Button;
class ScrollView;
class Segmented;
class NotificationsTab : public Tab {
public:
@@ -26,13 +28,17 @@ private:
void rebuild(Renderer& renderer, float width);
NotificationManager* m_notifications = nullptr;
IconResolver m_iconResolver;
Flex* m_root = nullptr;
ScrollView* m_scroll = nullptr;
Flex* m_list = nullptr;
Button* m_clearAllButton = nullptr;
Segmented* m_filter = nullptr;
std::size_t m_filterIndex = 0;
std::unordered_set<uint32_t> m_expandedIds;
std::uint64_t m_lastSerial = 0;
float m_lastWidth = -1.0f;
/// Wall-clock coarse slot so relative times (e.g. "2 min ago") refresh without churning every frame.
std::int64_t m_lastRelativeTimeSlot = -1;
std::size_t m_lastRebuildFilterIndex = static_cast<std::size_t>(-1);
};
+15
View File
@@ -78,10 +78,25 @@ Button* Segmented::makeSegmentButton(std::string_view label, std::string_view gl
btn->setPadding(Style::spaceXs * m_scale, Style::spaceMd * m_scale);
btn->setOnClick([this, index]() { setSelectedIndex(index); });
Button* raw = btn.get();
raw->setFlexGrow(m_equalSegmentWidths ? 1.0f : 0.0f);
raw->setContentAlign(ButtonContentAlign::Center);
addChild(std::move(btn));
return raw;
}
void Segmented::setEqualSegmentWidths(bool equalWidths) {
if (m_equalSegmentWidths == equalWidths) {
return;
}
m_equalSegmentWidths = equalWidths;
for (Button* b : m_buttons) {
if (b != nullptr) {
b->setFlexGrow(m_equalSegmentWidths ? 1.0f : 0.0f);
}
}
markLayoutDirty();
}
void Segmented::refreshVariants() {
const std::size_t n = m_buttons.size();
const float r = Style::radiusMd * m_scale;
+4
View File
@@ -24,6 +24,9 @@ public:
void setOnChange(std::function<void(std::size_t)> callback);
// When true, each segment gets flexGrow 1 so the group fills the available width (e.g. full bar).
void setEqualSegmentWidths(bool equalWidths);
private:
Button* makeSegmentButton(std::string_view label, std::string_view glyph, std::size_t index);
void refreshVariants();
@@ -35,4 +38,5 @@ private:
std::function<void(std::size_t)> m_onChange;
float m_fontSize = 0.0f;
float m_scale = 1.0f;
bool m_equalSegmentWidths = false;
};