Merge branch 'v5' of github.com:noctalia-dev/noctalia-shell into v5

This commit is contained in:
Lemmy
2026-05-03 09:15:32 -04:00
36 changed files with 1899 additions and 258 deletions
+24 -6
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",
@@ -553,7 +561,7 @@
"unknown-value": "Unknown: {value}"
},
"list": {
"add-placeholder": "Pick…"
"add-entry-placeholder": "Add entry…"
}
},
"dialogs": {
@@ -703,6 +711,7 @@
"interface": "Interface",
"layout": "Layout",
"location": "Location",
"media": "Media",
"motion": "Motion",
"network": "Network",
"night-light": "Night Light",
@@ -1142,7 +1151,8 @@
"shell": {
"avatar-path": {
"label": "Avatar Path",
"description": "Path to a custom avatar image"
"description": "Path to a custom avatar image",
"placeholder": "/path/to/avatar.png"
},
"offline-mode": {
"label": "Offline Mode",
@@ -1296,11 +1306,13 @@
},
"directory-light": {
"label": "Light Mode Directory",
"description": "Wallpaper folder used in Light theme mode"
"description": "Wallpaper folder used in Light theme mode",
"placeholder": "~/Pictures/Wallpapers"
},
"directory-dark": {
"label": "Dark Mode Directory",
"description": "Wallpaper folder used in Dark theme mode"
"description": "Wallpaper folder used in Dark theme mode",
"placeholder": "~/Pictures/Wallpapers"
},
"transitions": {
"label": "Transition Effects",
@@ -1378,11 +1390,17 @@
},
"volume-change-sound": {
"label": "Volume Change Sound",
"description": "Sound file used for volume feedback"
"description": "Sound file used for volume feedback",
"placeholder": "/path/to/sound.ogg"
},
"notification-sound": {
"label": "Notification Sound",
"description": "Sound file used for notification feedback"
"description": "Sound file used for notification feedback",
"placeholder": "/path/to/sound.ogg"
},
"mpris-blacklist": {
"label": "MPRIS player blacklist",
"description": "Players to ignore for media controls and Now Playing."
},
"ddcutil": {
"label": "DDC/CI (ddcutil)",
+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',
+10
View File
@@ -3,6 +3,7 @@
#include "app/poll_source.h"
#include "compositors/compositor_detect.h"
#include "compositors/output_backend.h"
#include "config/config_types.h"
#include "core/build_info.h"
#include "core/deferred_call.h"
#include "core/log.h"
@@ -112,6 +113,7 @@ namespace {
} // namespace
Application::Application() : m_weatherService(m_configService, m_httpClient) {
m_notificationManager.loadPersistedHistory();
notify::setInstance(&m_notificationManager);
LockScreen::setInstance(&m_lockScreen);
@@ -130,6 +132,10 @@ Application::Application() : m_weatherService(m_configService, m_httpClient) {
const char* origin = (n.origin == NotificationOrigin::Internal) ? "internal" : "external";
kLog.debug("notification {} id={} origin={}", kind, n.id, origin);
if (event == NotificationEvent::Added && m_panelManager.isActivePanelContext("notifications")) {
m_notificationManager.markNotificationHistorySeen();
}
// Keep bar widgets in sync with notification state changes.
m_bar.refresh();
if (shouldRefreshControlCenter()) {
@@ -146,6 +152,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);
@@ -347,6 +354,9 @@ void Application::initServices() {
m_wayland.setClipboardService(&m_clipboardService);
m_wayland.setVirtualKeyboardService(&m_virtualKeyboardService);
Input::setClipboardService(&m_clipboardService);
Input::setValidateKeyMatcher([this](std::uint32_t sym, std::uint32_t modifiers) {
return m_configService.matchesKeybind(KeybindAction::Validate, sym, modifiers);
});
m_wayland.setOutputChangeCallback([this]() {
if (m_brightnessService != 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);
+83 -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,12 +173,15 @@ 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);
const auto& n = m_notifications.back();
log_notification(n, "added");
upsertHistory(n, true, std::nullopt);
m_unreadSinceHistoryVisit = true;
for (auto& [token, cb] : m_eventCallbacks) {
cb(n, NotificationEvent::Added);
@@ -260,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() {
@@ -270,6 +289,8 @@ void NotificationManager::clearHistory() {
m_history.clear();
m_historyIndex.clear();
++m_changeSerial;
markNotificationHistorySeen();
schedulePersistHistory();
}
std::vector<uint32_t> NotificationManager::expiredIds() const {
@@ -310,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) {
@@ -319,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) {
@@ -344,3 +370,59 @@ bool NotificationManager::toggleDoNotDisturb() {
void NotificationManager::setStateCallback(StateCallback callback) { m_stateCallback = std::move(callback); }
void NotificationManager::setSoundPlayer(SoundPlayer* soundPlayer) { m_soundPlayer = soundPlayer; }
bool NotificationManager::hasUnreadNotificationHistory() const noexcept { return m_unreadSinceHistoryVisit; }
void NotificationManager::markNotificationHistorySeen() {
if (!m_unreadSinceHistoryVisit) {
return;
}
m_unreadSinceHistoryVisit = false;
if (m_stateCallback) {
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(); }
+15
View File
@@ -86,9 +86,23 @@ public:
void setStateCallback(StateCallback callback);
void setSoundPlayer(class SoundPlayer* soundPlayer);
// Bar indicator: true when at least one notification was added since the user last
// viewed the notification history (control center notifications tab).
[[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;
@@ -101,5 +115,6 @@ private:
uint32_t m_nextId{1};
std::uint64_t m_changeSerial{0};
bool m_doNotDisturb = false;
bool m_unreadSinceHistoryVisit = false;
class SoundPlayer* m_soundPlayer = nullptr;
};
+156 -77
View File
@@ -8,6 +8,7 @@
#include <algorithm>
#include <charconv>
#include <chrono>
#include <cmath>
#include <cstring>
#include <memory>
@@ -21,6 +22,7 @@
#include <spa/pod/builder.h>
#include <spa/pod/iter.h>
#include <spa/pod/parser.h>
#include <spa/utils/defs.h>
#include <spa/utils/result.h>
#include <string>
#include <string_view>
@@ -28,6 +30,7 @@
namespace {
constexpr float kDefaultVolumeStep = 0.05f;
constexpr auto kVolumeApplyMinInterval = std::chrono::milliseconds(25);
// Registry events.
void onRegistryGlobal(void* data, std::uint32_t id, std::uint32_t, const char* type, std::uint32_t version,
@@ -191,26 +194,6 @@ namespace {
return std::nullopt;
}
void applyVolumePropsFromDict(PipeWireService::NodeData& nd, const spa_dict* props) {
if (props == nullptr) {
return;
}
if (const auto maybeChannelmixVolume = parseFloat(dictGet(props, "channelmix.volume"));
maybeChannelmixVolume.has_value()) {
nd.volume = std::clamp(*maybeChannelmixVolume, 0.0f, 1.5f);
} else if (const auto maybeVolume = parseFloat(dictGet(props, "volume")); maybeVolume.has_value()) {
nd.volume = std::clamp(*maybeVolume, 0.0f, 1.5f);
}
if (const auto maybeChannelmixMuted = parseBool(dictGet(props, "channelmix.mute"));
maybeChannelmixMuted.has_value()) {
nd.muted = *maybeChannelmixMuted;
} else if (const auto maybeMuted = parseBool(dictGet(props, "mute")); maybeMuted.has_value()) {
nd.muted = *maybeMuted;
}
}
bool applyClientPropsFromDict(PipeWireService::ClientData& client, const spa_dict* props) {
if (props == nullptr) {
return false;
@@ -335,6 +318,9 @@ PipeWireService::PipeWireService() {
}
PipeWireService::~PipeWireService() {
m_volumeThrottleTimer.stop();
m_pendingNodeVolumes.clear();
// Destroy node proxies and their listeners
for (auto& [id, nd] : m_nodes) {
if (nd->listener != nullptr) {
@@ -618,6 +604,12 @@ void PipeWireService::onRegistryGlobalRemove(std::uint32_t id) {
pw_proxy_destroy(reinterpret_cast<pw_proxy*>(it->second.proxy));
}
m_devices.erase(it);
for (auto& [nid, node] : m_nodes) {
if (node != nullptr && node->deviceId == id) {
recomputeEffectiveMute(*node);
}
}
rebuildState();
return;
}
@@ -750,13 +742,14 @@ void PipeWireService::onNodeParam(std::uint32_t id, std::uint32_t paramId, std::
auto* propsObj = reinterpret_cast<spa_pod_object*>(const_cast<spa_pod*>(routeProps));
SPA_POD_OBJECT_FOREACH(propsObj, prop) {
if (prop->key == SPA_PROP_mute) {
bool muted = false;
if (spa_pod_get_bool(&prop->value, &muted) == 0) {
nd.muted = muted;
bool routeMuted = false;
if (spa_pod_get_bool(&prop->value, &routeMuted) == 0) {
nd.nodeRouteMute = routeMuted;
}
}
}
}
recomputeEffectiveMute(nd);
rebuildState();
}
return;
@@ -789,9 +782,9 @@ void PipeWireService::onNodeParam(std::uint32_t id, std::uint32_t paramId, std::
parseVolumeArrayProp(prop, parsedSoftVolumes);
hasSoftVolumes = true;
} else if (prop->key == SPA_PROP_mute) {
bool muted = false;
if (spa_pod_get_bool(&prop->value, &muted) == 0) {
nd.muted = muted;
bool swMuted = false;
if (spa_pod_get_bool(&prop->value, &swMuted) == 0) {
nd.swMute = swMuted;
}
}
}
@@ -805,6 +798,8 @@ void PipeWireService::onNodeParam(std::uint32_t id, std::uint32_t paramId, std::
nd.volume = parsedSoftVolumes;
}
recomputeEffectiveMute(nd);
if (isProgramStreamClass(nd.mediaClass)) {
kLog.debug("[program-stream] node-param id={} class='{}' volume={:.3f} muted={} channels={}", id, nd.mediaClass,
nd.volume, nd.muted, nd.channelCount);
@@ -903,6 +898,13 @@ void PipeWireService::onDeviceParam(std::uint32_t id, std::uint32_t paramId, std
existing->direction = routeDirection;
existing->muted = muted;
}
for (auto& [nid, node] : m_nodes) {
if (node != nullptr && node->deviceId == id) {
recomputeEffectiveMute(*node);
}
}
rebuildState();
}
void PipeWireService::parseDefaultNodes(const spa_dict* props) {
@@ -995,15 +997,103 @@ void PipeWireService::rebuildState() {
emitChanged();
}
void PipeWireService::setNodeVolume(std::uint32_t id, float volume) {
bool PipeWireService::deviceRouteIndicatesMuted(const NodeData& nd) const {
if (nd.deviceId == 0) {
return false;
}
const auto it = m_devices.find(nd.deviceId);
if (it == m_devices.end()) {
return false;
}
std::uint32_t wantDir = 0;
if (nd.mediaClass == "Audio/Source") {
wantDir = SPA_DIRECTION_INPUT;
} else if (nd.mediaClass == "Audio/Sink") {
wantDir = SPA_DIRECTION_OUTPUT;
} else {
return false;
}
for (const auto& r : it->second.routes) {
if (r.direction == wantDir && r.index >= 0 && r.muted) {
return true;
}
}
return false;
}
void PipeWireService::recomputeEffectiveMute(NodeData& nd) {
nd.muted = nd.swMute || nd.nodeRouteMute || deviceRouteIndicatesMuted(nd);
}
void PipeWireService::applyVolumePropsFromDict(NodeData& nd, const spa_dict* props) {
if (props == nullptr) {
return;
}
if (const auto maybeChannelmixVolume = parseFloat(dictGet(props, "channelmix.volume"));
maybeChannelmixVolume.has_value()) {
nd.volume = std::clamp(*maybeChannelmixVolume, 0.0f, 1.5f);
} else if (const auto maybeVolume = parseFloat(dictGet(props, "volume")); maybeVolume.has_value()) {
nd.volume = std::clamp(*maybeVolume, 0.0f, 1.5f);
}
if (const auto maybeChannelmixMuted = parseBool(dictGet(props, "channelmix.mute"));
maybeChannelmixMuted.has_value()) {
nd.swMute = *maybeChannelmixMuted;
} else if (const auto maybeMuted = parseBool(dictGet(props, "mute")); maybeMuted.has_value()) {
nd.swMute = *maybeMuted;
}
recomputeEffectiveMute(nd);
}
void PipeWireService::scheduleVolumeFlush() {
const auto now = std::chrono::steady_clock::now();
const auto earliest = m_lastVolumeFlushValid ? (m_lastVolumeFlushAt + kVolumeApplyMinInterval)
: std::chrono::steady_clock::time_point{};
if (!m_lastVolumeFlushValid || now >= earliest) {
m_volumeThrottleTimer.stop();
flushPendingNodeVolumes();
m_lastVolumeFlushAt = std::chrono::steady_clock::now();
m_lastVolumeFlushValid = true;
return;
}
const auto delay = std::chrono::duration_cast<std::chrono::milliseconds>(earliest - now);
const auto wait = std::max(delay, std::chrono::milliseconds{1});
m_volumeThrottleTimer.start(wait, [this]() {
flushPendingNodeVolumes();
m_lastVolumeFlushAt = std::chrono::steady_clock::now();
});
}
void PipeWireService::flushPendingNodeVolumes() {
if (m_pendingNodeVolumes.empty()) {
return;
}
bool dirty = false;
auto pending = std::move(m_pendingNodeVolumes);
for (const auto& [id, volume] : pending) {
dirty |= applyNodeVolumeImmediate(id, volume);
}
if (dirty) {
rebuildState();
}
}
bool PipeWireService::applyNodeVolumeImmediate(std::uint32_t id, float volume) {
auto it = m_nodes.find(id);
if (it == m_nodes.end()) {
return;
return false;
}
auto& nd = *it->second;
if (nd.proxy == nullptr) {
return;
return false;
}
volume = std::clamp(volume, 0.0f, 1.5f);
@@ -1016,9 +1106,9 @@ void PipeWireService::setNodeVolume(std::uint32_t id, float volume) {
if (updatedViaWpctl) {
if (std::abs(nd.volume - volume) >= 0.0001f) {
nd.volume = volume;
rebuildState();
return true;
}
return;
return false;
}
}
@@ -1043,8 +1133,23 @@ void PipeWireService::setNodeVolume(std::uint32_t id, float volume) {
// Apply optimistic local state while PipeWire publishes props.
if (std::abs(nd.volume - volume) >= 0.0001f) {
nd.volume = volume;
rebuildState();
return true;
}
return false;
}
void PipeWireService::setNodeVolume(std::uint32_t id, float volume) {
auto it = m_nodes.find(id);
if (it == m_nodes.end()) {
return;
}
if (it->second->proxy == nullptr) {
return;
}
m_pendingNodeVolumes[id] = std::clamp(volume, 0.0f, 1.5f);
scheduleVolumeFlush();
}
void PipeWireService::setNodeMuted(std::uint32_t id, bool muted) {
@@ -1058,53 +1163,23 @@ void PipeWireService::setNodeMuted(std::uint32_t id, bool muted) {
return;
}
// Match WirePlumber session policy (same as set-volume) so mute state stays consistent
// with wpctl and survives odd daemon/node prop ordering after resume/reboot.
const bool isDeviceNode = nd.mediaClass == "Audio/Sink" || nd.mediaClass == "Audio/Source";
if (isDeviceNode && nd.deviceId != 0) {
auto devIt = m_devices.find(nd.deviceId);
if (devIt != m_devices.end() && devIt->second.proxy != nullptr) {
const std::uint32_t targetDirection =
(nd.mediaClass == "Audio/Source") ? SPA_DIRECTION_INPUT : SPA_DIRECTION_OUTPUT;
bool wroteDeviceRoute = false;
for (const auto& route : devIt->second.routes) {
if (route.index < 0 || route.direction != targetDirection) {
continue;
}
std::uint8_t routeBuffer[512];
spa_pod_builder routeBuilder;
spa_pod_builder_init(&routeBuilder, routeBuffer, sizeof(routeBuffer));
spa_pod_frame routeFrame;
spa_pod_builder_push_object(&routeBuilder, &routeFrame, SPA_TYPE_OBJECT_ParamRoute, SPA_PARAM_Route);
spa_pod_builder_prop(&routeBuilder, SPA_PARAM_ROUTE_index, 0);
spa_pod_builder_int(&routeBuilder, route.index);
spa_pod_builder_prop(&routeBuilder, SPA_PARAM_ROUTE_direction, 0);
spa_pod_builder_id(&routeBuilder, route.direction);
spa_pod_builder_prop(&routeBuilder, SPA_PARAM_ROUTE_device, 0);
spa_pod_builder_int(&routeBuilder, route.device);
spa_pod_builder_prop(&routeBuilder, SPA_PARAM_ROUTE_props, 0);
spa_pod_frame routePropsFrame;
spa_pod_builder_push_object(&routeBuilder, &routePropsFrame, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props);
spa_pod_builder_prop(&routeBuilder, SPA_PROP_mute, 0);
spa_pod_builder_bool(&routeBuilder, muted);
spa_pod_builder_pop(&routeBuilder, &routePropsFrame);
spa_pod_builder_prop(&routeBuilder, SPA_PARAM_ROUTE_save, 0);
spa_pod_builder_bool(&routeBuilder, true);
auto* routePod = static_cast<spa_pod*>(spa_pod_builder_pop(&routeBuilder, &routeFrame));
pw_device_set_param(devIt->second.proxy, SPA_PARAM_Route, 0, routePod);
wroteDeviceRoute = true;
}
if (wroteDeviceRoute) {
if (nd.muted != muted) {
nd.muted = muted;
rebuildState();
}
return;
if (isDeviceNode) {
const bool updatedViaWpctl = process::runSync({"wpctl", "set-mute", std::to_string(id), muted ? "1" : "0"});
if (updatedViaWpctl) {
const bool before = nd.muted;
nd.swMute = muted;
recomputeEffectiveMute(nd);
if (before != nd.muted) {
rebuildState();
}
return;
}
}
// Program streams, or device fallback when wpctl is unavailable.
if (nd.hasRoute && nd.routeIndex >= 0) {
std::uint8_t routeBuffer[512];
spa_pod_builder routeBuilder;
@@ -1142,9 +1217,13 @@ void PipeWireService::setNodeMuted(std::uint32_t id, bool muted) {
pw_node_set_param(nd.proxy, SPA_PARAM_Props, 0, pod);
// Apply optimistic local state while PipeWire publishes props.
if (nd.muted != muted) {
nd.muted = muted;
const bool before = nd.muted;
nd.swMute = muted;
if (nd.hasRoute && nd.routeIndex >= 0) {
nd.nodeRouteMute = muted;
}
recomputeEffectiveMute(nd);
if (before != nd.muted) {
rebuildState();
}
}
+20
View File
@@ -1,5 +1,8 @@
#pragma once
#include "core/timer_manager.h"
#include <chrono>
#include <cstdint>
#include <functional>
#include <memory>
@@ -14,6 +17,7 @@ struct pw_device;
struct pw_loop;
struct pw_registry;
struct spa_hook;
struct spa_dict;
class ConfigService;
class IpcService;
@@ -109,6 +113,10 @@ public:
std::string iconName;
std::string mediaClass;
float volume = 1.0f;
// Software / node-route mute from PipeWire props (SPA_PARAM_Props, node routes).
bool swMute = false;
bool nodeRouteMute = false;
// Effective mute for UI (includes device-route mute, e.g. USB mic hardware switch).
bool muted = false;
std::uint32_t channelCount = 0;
std::uint32_t deviceId = 0;
@@ -156,10 +164,22 @@ public:
private:
void rebuildState();
void refreshNodeIdentity(NodeData& nd);
void applyVolumePropsFromDict(NodeData& nd, const spa_dict* props);
void recomputeEffectiveMute(NodeData& nd);
[[nodiscard]] bool deviceRouteIndicatesMuted(const NodeData& nd) const;
void setNodeVolume(std::uint32_t id, float volume);
void setNodeMuted(std::uint32_t id, bool muted);
void setDefaultNode(std::uint32_t id, const char* key);
void scheduleVolumeFlush();
void flushPendingNodeVolumes();
[[nodiscard]] bool applyNodeVolumeImmediate(std::uint32_t id, float volume);
Timer m_volumeThrottleTimer;
std::unordered_map<std::uint32_t, float> m_pendingNodeVolumes;
std::chrono::steady_clock::time_point m_lastVolumeFlushAt{};
bool m_lastVolumeFlushValid = false;
pw_loop* m_loop = nullptr;
pw_context* m_context = nullptr;
pw_core* m_core = nullptr;
@@ -78,7 +78,7 @@ void NotificationWidget::doLayout(Renderer& renderer, float /*containerWidth*/,
void NotificationWidget::doUpdate(Renderer& /*renderer*/) { refreshIndicatorState(); }
void NotificationWidget::refreshIndicatorState() {
const bool hasNotifications = (m_manager != nullptr) && !m_manager->all().empty();
const bool hasNotifications = (m_manager != nullptr) && m_manager->hasUnreadNotificationHistory();
const bool dndEnabled = (m_manager != nullptr) && m_manager->doNotDisturb();
if (hasNotifications == m_hasNotifications && dndEnabled == m_dndEnabled) {
return;
+93 -14
View File
@@ -33,6 +33,7 @@ namespace {
constexpr float kRowHeight = 46.0f;
constexpr float kPreviewImageHeight = 280.0f;
constexpr float kListGlyphSize = 24.0f;
constexpr float kListThumbSize = 40.0f;
constexpr auto kPreviewPayloadDebounceInterval = std::chrono::milliseconds(75);
constexpr auto kFilterDebounceInterval = std::chrono::milliseconds(120);
@@ -213,6 +214,11 @@ void ClipboardPanel::create() {
previewTitleLabel->setFlexGrow(1.0f);
previewHeader->addChild(std::move(previewTitleLabel));
auto previewActions = std::make_unique<Flex>();
previewActions->setDirection(FlexDirection::Horizontal);
previewActions->setAlign(FlexAlign::Center);
previewActions->setGap(Style::spaceXs * scale);
auto copyButton = std::make_unique<Button>();
copyButton->setGlyph("copy");
copyButton->setVariant(ButtonVariant::Secondary);
@@ -223,7 +229,21 @@ void ClipboardPanel::create() {
copyButton->setRadius(Style::radiusMd * scale);
copyButton->setOnClick([this]() { activateSelected(); });
m_copyButton = copyButton.get();
previewHeader->addChild(std::move(copyButton));
previewActions->addChild(std::move(copyButton));
auto deleteEntryButton = std::make_unique<Button>();
deleteEntryButton->setGlyph("trash");
deleteEntryButton->setVariant(ButtonVariant::Destructive);
deleteEntryButton->setGlyphSize(Style::fontSizeBody * scale);
deleteEntryButton->setMinWidth(Style::controlHeightSm * scale);
deleteEntryButton->setMinHeight(Style::controlHeightSm * scale);
deleteEntryButton->setPadding(Style::spaceXs * scale);
deleteEntryButton->setRadius(Style::radiusMd * scale);
deleteEntryButton->setOnClick([this]() { deleteSelectedEntry(); });
m_deleteEntryButton = deleteEntryButton.get();
previewActions->addChild(std::move(deleteEntryButton));
previewHeader->addChild(std::move(previewActions));
preview->addChild(std::move(previewHeader));
auto previewMetaLabel = std::make_unique<Label>();
@@ -362,6 +382,7 @@ void ClipboardPanel::onClose() {
m_previewTitle = nullptr;
m_previewMeta = nullptr;
m_copyButton = nullptr;
m_deleteEntryButton = nullptr;
m_previewScrollView = nullptr;
m_previewContent = nullptr;
m_previewImage = nullptr;
@@ -440,18 +461,25 @@ void ClipboardPanel::rebuildList(Renderer& renderer, float width) {
return;
}
const float textWidth = std::max(0.0f, width - kListGlyphSize - Style::spaceMd - Style::spaceSm * 2.0f);
const float scale = contentScale();
const float thumbPx = kListThumbSize * scale;
const float leadW = thumbPx;
const float textWidth = std::max(0.0f, width - leadW - Style::spaceMd * scale - Style::spaceSm * scale * 2.0f);
for (std::size_t i = 0; i < m_filteredIndices.size(); ++i) {
const std::size_t historyIdx = m_filteredIndices[i];
const auto& entry = history[historyIdx];
ClipboardEntry rowEntry = history[historyIdx];
if (rowEntry.isImage() && m_clipboard != nullptr) {
(void)m_clipboard->ensureEntryLoaded(historyIdx);
rowEntry = history[historyIdx];
}
auto row = std::make_unique<Flex>();
row->setDirection(FlexDirection::Horizontal);
row->setAlign(FlexAlign::Center);
row->setGap(Style::spaceMd);
row->setPadding(Style::spaceXs, Style::spaceSm, Style::spaceXs, Style::spaceSm);
row->setPadding(Style::spaceXs * scale, Style::spaceSm * scale, Style::spaceXs * scale, Style::spaceSm * scale);
row->setSize(width, 0.0f);
row->setFillWidth(true);
row->setMinHeight(kRowHeight);
row->setMinHeight(std::max(kRowHeight * scale, thumbPx));
row->setRadius(Style::radiusMd);
if (i == m_selectedIndex) {
row->setFill(colorSpecFromRole(ColorRole::SurfaceVariant));
@@ -494,19 +522,42 @@ void ClipboardPanel::rebuildList(Renderer& renderer, float width) {
PanelManager::instance().refresh();
});
auto glyph = std::make_unique<Glyph>();
glyph->setGlyph(entry.isImage() ? "photo" : "file-text");
glyph->setGlyphSize(kListGlyphSize);
glyph->setColor(colorSpecFromRole(entry.isImage() ? ColorRole::Secondary : ColorRole::Primary));
row->addChild(std::move(glyph));
auto lead = std::make_unique<Flex>();
lead->setDirection(FlexDirection::Horizontal);
lead->setAlign(FlexAlign::Center);
lead->setJustify(FlexJustify::Center);
lead->setSize(leadW, 0.0f);
if (rowEntry.isImage() && !rowEntry.data.empty()) {
auto thumb = std::make_unique<Image>();
thumb->setSize(thumbPx, thumbPx);
thumb->setFit(ImageFit::Cover);
thumb->setRadius(Style::radiusMd * scale);
if (thumb->setSourceBytes(renderer, rowEntry.data.data(), rowEntry.data.size())) {
lead->addChild(std::move(thumb));
} else {
auto fallback = std::make_unique<Glyph>();
fallback->setGlyph("photo");
fallback->setGlyphSize(kListGlyphSize * scale);
fallback->setColor(colorSpecFromRole(ColorRole::Secondary));
lead->addChild(std::move(fallback));
}
} else {
auto glyph = std::make_unique<Glyph>();
glyph->setGlyph(rowEntry.isImage() ? "photo" : "file-text");
glyph->setGlyphSize(kListGlyphSize * scale);
glyph->setColor(colorSpecFromRole(rowEntry.isImage() ? ColorRole::Secondary : ColorRole::Primary));
lead->addChild(std::move(glyph));
}
row->addChild(std::move(lead));
auto textColumn = std::make_unique<Flex>();
textColumn->setDirection(FlexDirection::Vertical);
textColumn->setAlign(FlexAlign::Start);
textColumn->setGap(Style::spaceXs);
textColumn->setGap(Style::spaceXs * scale);
const std::string rawTitle = entryTitle(entry);
const std::string cleanTitle = entry.isImage() ? rawTitle : collapseWhitespace(rawTitle);
const std::string rawTitle = entryTitle(rowEntry);
const std::string cleanTitle = rowEntry.isImage() ? rawTitle : collapseWhitespace(rawTitle);
auto title = std::make_unique<Label>();
title->setText(cleanTitle);
title->setFontSize(Style::fontSizeBody);
@@ -514,13 +565,15 @@ void ClipboardPanel::rebuildList(Renderer& renderer, float width) {
title->setColor(colorSpecFromRole(ColorRole::OnSurface));
title->setMaxWidth(textWidth);
title->setMaxLines(1);
title->setHitTestVisible(false);
textColumn->addChild(std::move(title));
auto timeLabel = std::make_unique<Label>();
timeLabel->setText(formatTimeAgo(entry.capturedAt) + "" + formatBytes(entry.byteSize));
timeLabel->setText(formatTimeAgo(rowEntry.capturedAt) + "" + formatBytes(rowEntry.byteSize));
timeLabel->setCaptionStyle();
timeLabel->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
timeLabel->setMaxWidth(textWidth);
timeLabel->setHitTestVisible(false);
textColumn->addChild(std::move(timeLabel));
row->addChild(std::move(textColumn));
@@ -736,6 +789,32 @@ void ClipboardPanel::selectIndex(std::size_t index) {
PanelManager::instance().refresh();
}
void ClipboardPanel::deleteSelectedEntry() {
if (m_clipboard == nullptr) {
return;
}
const std::size_t historyIndex = selectedHistoryIndex();
if (historyIndex == static_cast<std::size_t>(-1)) {
return;
}
const std::size_t filterPos = m_selectedIndex;
if (!m_clipboard->removeHistoryEntry(historyIndex)) {
return;
}
applyFilter();
if (m_filteredIndices.empty()) {
m_selectedIndex = 0;
m_hoverIndex = static_cast<std::size_t>(-1);
m_mouseActive = false;
} else {
m_selectedIndex = std::min(filterPos, m_filteredIndices.size() - 1);
}
schedulePreviewPayloadRefresh(false);
m_lastListWidth = -1.0f;
m_pendingScrollToSelected = true;
PanelManager::instance().refresh();
}
void ClipboardPanel::activateSelected() {
if (m_clipboard == nullptr) {
return;
+2
View File
@@ -49,6 +49,7 @@ private:
void activateSelected();
bool handleKeyEvent(std::uint32_t sym, std::uint32_t modifiers);
void scrollToSelected();
void deleteSelectedEntry();
void applyFilter();
void onFilterChanged(const std::string& text);
[[nodiscard]] std::size_t selectedHistoryIndex() const;
@@ -75,6 +76,7 @@ private:
Label* m_previewTitle = nullptr;
Label* m_previewMeta = nullptr;
Button* m_copyButton = nullptr;
Button* m_deleteEntryButton = nullptr;
ScrollView* m_previewScrollView = nullptr;
Flex* m_previewContent = nullptr;
Image* m_previewImage = nullptr;
@@ -1,6 +1,7 @@
#include "shell/control_center/control_center_panel.h"
#include "i18n/i18n.h"
#include "notification/notification_manager.h"
#include "render/core/renderer.h"
#include "render/scene/input_area.h"
#include "shell/panel/panel_manager.h"
@@ -22,6 +23,7 @@ ControlCenterPanel::ControlCenterPanel(NotificationManager* notifications, PipeW
noctalia::theme::ThemeService* theme, IdleInhibitor* idleInhibitor,
WaylandConnection* wayland, Wallpaper* wallpaper) {
(void)upower;
m_notificationManager = notifications;
m_tabs[tabIndex(TabId::Overview)] =
std::make_unique<OverviewTab>(mpris, weather, audio, powerProfiles, config, network, bluetooth, nightLight, theme,
notifications, idleInhibitor, wayland, wallpaper);
@@ -275,6 +277,9 @@ bool ControlCenterPanel::deferPointerRelayout() const { return deferExternalRefr
void ControlCenterPanel::selectTab(TabId tab) {
m_activeTab = tab;
if (tab == TabId::Notifications && m_notificationManager != nullptr) {
m_notificationManager->markNotificationHistorySeen();
}
for (const auto& meta : kTabs) {
const std::size_t idx = tabIndex(meta.id);
if (m_tabContainers[idx] != nullptr) {
@@ -134,4 +134,5 @@ private:
std::array<Flex*, kTabCount> m_tabContainers{};
std::array<Flex*, kTabCount> m_tabHeaderActions{};
TabId m_activeTab = TabId::Overview;
NotificationManager* m_notificationManager = nullptr;
};
+231 -13
View File
@@ -2,24 +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;
@@ -54,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()) {
@@ -79,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);
@@ -118,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;
}
@@ -155,10 +265,12 @@ void NotificationsTab::onClose() {
m_root = nullptr;
m_scroll = nullptr;
m_list = nullptr;
m_filter = nullptr;
m_clearAllButton = nullptr;
m_expandedIds.clear();
m_lastSerial = 0;
m_lastWidth = -1.0f;
m_lastRelativeTimeSlot = -1;
}
void NotificationsTab::clearAllNotifications() {
@@ -214,7 +326,11 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
}
const std::uint64_t serial = m_notifications != nullptr ? m_notifications->changeSerial() : 0;
if (serial == m_lastSerial && std::abs(width - m_lastWidth) < 0.5f) {
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 &&
m_filterIndex == m_lastRebuildFilterIndex) {
return;
}
@@ -254,25 +370,69 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
m_list->addChild(std::move(empty));
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);
@@ -284,15 +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>();
meta->setText(it->notification.appName + "" + statusText(*it));
std::string metaLine = entry->notification.appName + "" + relativeMetaLine(entry->notification);
if (!entry->active) {
metaLine += "";
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);
@@ -308,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));
}
@@ -321,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));
@@ -351,4 +567,6 @@ 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,11 +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);
};
+7
View File
@@ -762,6 +762,13 @@ bool PanelManager::isAttachedOpen() const noexcept { return isOpen() && m_attach
const std::string& PanelManager::activePanelId() const noexcept { return m_activePanelId; }
bool PanelManager::isActivePanelContext(std::string_view context) const noexcept {
if (!isOpen() || m_activePanel == nullptr) {
return false;
}
return m_activePanel->isContextActive(context);
}
void PanelManager::refresh() {
if (!isOpen() || m_renderContext == nullptr || m_activePanel == nullptr || m_surface == nullptr) {
return;
+2
View File
@@ -77,6 +77,8 @@ public:
[[nodiscard]] bool isOpen() const noexcept;
[[nodiscard]] bool isAttachedOpen() const noexcept;
[[nodiscard]] const std::string& activePanelId() const noexcept;
// True when a panel is open and it reports the given context as active (e.g. control-center tab).
[[nodiscard]] bool isActivePanelContext(std::string_view context) const noexcept;
[[nodiscard]] std::optional<LayerPopupParentContext> popupParentContextForSurface(wl_surface* surface) const noexcept;
[[nodiscard]] std::optional<LayerPopupParentContext> fallbackPopupParentContext() const noexcept;
+14 -13
View File
@@ -514,7 +514,8 @@ namespace settings {
const auto makeText = [&](const std::string& value, const std::string& placeholder, std::vector<std::string> path) {
auto input = std::make_unique<Input>();
input->setValue(value);
input->setPlaceholder(placeholder);
input->setPlaceholder(placeholder.empty() ? i18n::tr("settings.controls.list.add-entry-placeholder")
: placeholder);
input->setFontSize(Style::fontSizeBody * scale);
input->setControlHeight(Style::controlHeight * scale);
input->setHorizontalPadding(Style::spaceSm * scale);
@@ -844,7 +845,7 @@ namespace settings {
auto addRow = std::make_unique<Flex>();
addRow->setDirection(FlexDirection::Horizontal);
addRow->setAlign(FlexAlign::Center);
addRow->setGap(Style::spaceXs * scale);
addRow->setGap(Style::spaceSm * scale);
const bool useSelectAdder = !list.suggestedOptions.empty();
std::vector<SelectOption> remaining;
@@ -872,7 +873,7 @@ namespace settings {
auto select = std::make_unique<Select>();
select->setOptions(remainingLabels);
select->setPlaceholder(i18n::tr("settings.controls.list.add-placeholder"));
select->setPlaceholder(i18n::tr("settings.controls.list.add-entry-placeholder"));
select->setFontSize(Style::fontSizeCaption * scale);
select->setControlHeight(Style::controlHeightSm * scale);
select->setGlyphSize(Style::fontSizeCaption * scale);
@@ -903,21 +904,21 @@ namespace settings {
addRow->addChild(std::move(addBtn));
} else {
auto addInput = std::make_unique<Input>();
addInput->setFontSize(Style::fontSizeCaption * scale);
addInput->setControlHeight(Style::controlHeightSm * scale);
addInput->setHorizontalPadding(Style::spaceXs * scale);
addInput->setSize(140.0f * scale, Style::controlHeightSm * scale);
addInput->setFlexGrow(1.0f);
addInput->setPlaceholder(i18n::tr("settings.controls.list.add-entry-placeholder"));
addInput->setFontSize(Style::fontSizeBody * scale);
addInput->setControlHeight(Style::controlHeight * scale);
addInput->setHorizontalPadding(Style::spaceSm * scale);
addInput->setSize(190.0f * scale, Style::controlHeight * scale);
auto* addInputPtr = addInput.get();
auto addBtn = std::make_unique<Button>();
addBtn->setGlyph("add");
addBtn->setVariant(ButtonVariant::Ghost);
addBtn->setGlyphSize(Style::fontSizeCaption * scale);
addBtn->setMinWidth(Style::controlHeightSm * scale);
addBtn->setMinHeight(Style::controlHeightSm * scale);
addBtn->setPadding(Style::spaceXs * scale);
addBtn->setRadius(Style::radiusSm * scale);
addBtn->setGlyphSize(Style::fontSizeBody * scale);
addBtn->setMinWidth(Style::controlHeight * scale);
addBtn->setMinHeight(Style::controlHeight * scale);
addBtn->setPadding(Style::spaceSm * scale);
addBtn->setRadius(Style::radiusMd * scale);
auto items = list.items;
auto path = entry.path;
addBtn->setOnClick([setOverride = ctx.setOverride, addInputPtr, items, path]() mutable {
+26 -17
View File
@@ -331,14 +331,16 @@ namespace settings {
entries.push_back(makeEntry("wallpaper", "directories", tr("settings.schema.wallpaper.directory.label"),
tr("settings.schema.wallpaper.directory.description"), {"wallpaper", "directory"},
TextSetting{cfg.wallpaper.directory, "~/Pictures/Wallpapers"}, "folder path"));
entries.push_back(makeEntry("wallpaper", "directories", tr("settings.schema.wallpaper.directory-light.label"),
tr("settings.schema.wallpaper.directory-light.description"),
{"wallpaper", "directory_light"}, TextSetting{cfg.wallpaper.directoryLight, ""},
"folder path light theme", true));
entries.push_back(makeEntry("wallpaper", "directories", tr("settings.schema.wallpaper.directory-dark.label"),
tr("settings.schema.wallpaper.directory-dark.description"),
{"wallpaper", "directory_dark"}, TextSetting{cfg.wallpaper.directoryDark, ""},
"folder path dark theme", true));
entries.push_back(makeEntry(
"wallpaper", "directories", tr("settings.schema.wallpaper.directory-light.label"),
tr("settings.schema.wallpaper.directory-light.description"), {"wallpaper", "directory_light"},
TextSetting{cfg.wallpaper.directoryLight, tr("settings.schema.wallpaper.directory-light.placeholder")},
"folder path light theme", true));
entries.push_back(
makeEntry("wallpaper", "directories", tr("settings.schema.wallpaper.directory-dark.label"),
tr("settings.schema.wallpaper.directory-dark.description"), {"wallpaper", "directory_dark"},
TextSetting{cfg.wallpaper.directoryDark, tr("settings.schema.wallpaper.directory-dark.placeholder")},
"folder path dark theme", true));
{
MultiSelectSetting transitions;
transitions.options.reserve(std::size(kWallpaperTransitions));
@@ -527,7 +529,8 @@ namespace settings {
// Shell
entries.push_back(makeEntry("shell", "profile", tr("settings.schema.shell.avatar-path.label"),
tr("settings.schema.shell.avatar-path.description"), {"shell", "avatar_path"},
TextSetting{cfg.shell.avatarPath, ""}, "image picture"));
TextSetting{cfg.shell.avatarPath, tr("settings.schema.shell.avatar-path.placeholder")},
"image picture"));
entries.push_back(makeEntry("shell", "network", tr("settings.schema.shell.offline-mode.label"),
tr("settings.schema.shell.offline-mode.description"), {"shell", "offline_mode"},
ToggleSetting{cfg.shell.offlineMode}, "network http fetch download"));
@@ -596,14 +599,20 @@ namespace settings {
entries.push_back(makeEntry("services", "audio", tr("settings.schema.services.sound-volume.label"),
tr("settings.schema.services.sound-volume.description"), {"audio", "sound_volume"},
SliderSetting{cfg.audio.soundVolume, 0.0f, 1.0f, 0.01f, false}, "sound"));
entries.push_back(makeEntry("services", "audio", tr("settings.schema.services.volume-change-sound.label"),
tr("settings.schema.services.volume-change-sound.description"),
{"audio", "volume_change_sound"}, TextSetting{cfg.audio.volumeChangeSound, ""},
"sound path file", true));
entries.push_back(makeEntry("services", "audio", tr("settings.schema.services.notification-sound.label"),
tr("settings.schema.services.notification-sound.description"),
{"audio", "notification_sound"}, TextSetting{cfg.audio.notificationSound, ""},
"sound path file", true));
entries.push_back(makeEntry(
"services", "audio", tr("settings.schema.services.volume-change-sound.label"),
tr("settings.schema.services.volume-change-sound.description"), {"audio", "volume_change_sound"},
TextSetting{cfg.audio.volumeChangeSound, tr("settings.schema.services.volume-change-sound.placeholder")},
"sound path file", true));
entries.push_back(makeEntry(
"services", "audio", tr("settings.schema.services.notification-sound.label"),
tr("settings.schema.services.notification-sound.description"), {"audio", "notification_sound"},
TextSetting{cfg.audio.notificationSound, tr("settings.schema.services.notification-sound.placeholder")},
"sound path file", true));
entries.push_back(makeEntry("services", "media", tr("settings.schema.services.mpris-blacklist.label"),
tr("settings.schema.services.mpris-blacklist.description"),
{"shell", "mpris", "blacklist"}, ListSetting{.items = cfg.shell.mpris.blacklist},
"mpris media player dbus session blacklist"));
entries.push_back(makeEntry("services", "brightness", tr("settings.schema.services.ddcutil.label"),
tr("settings.schema.services.ddcutil.description"), {"brightness", "enable_ddcutil"},
ToggleSetting{cfg.brightness.enableDdcutil}, "monitor ddcutil"));
+23
View File
@@ -218,6 +218,29 @@ void TestPanel::create() {
colA->addChild(std::move(section));
}
// Label (auto-scroll)
{
auto marquee = std::make_unique<Label>();
marquee->setText("This label scrolls automatically when the line is longer than its layout width :p");
marquee->setFontSize(Style::fontSizeBody * scale);
marquee->setMaxWidth(240.0f * scale);
marquee->setAutoScroll(true);
marquee->setAutoScrollSpeed(42.0f * scale);
auto marqueeHover = std::make_unique<Label>();
marqueeHover->setText("Hover this row to scroll - the marquee pauses when the pointer leaves the label.");
marqueeHover->setFontSize(Style::fontSizeBody * scale);
marqueeHover->setMaxWidth(240.0f * scale);
marqueeHover->setAutoScroll(true);
marqueeHover->setAutoScrollSpeed(42.0f * scale);
marqueeHover->setAutoScrollOnlyWhenHovered(true);
auto section = makeSection("Label (auto-scroll)");
section->addChild(std::move(marquee));
section->addChild(std::move(marqueeHover));
colA->addChild(std::move(section));
}
// Slider
{
auto slider = std::make_unique<Slider>();
+43 -21
View File
@@ -12,6 +12,43 @@
#include <optional>
#include <string_view>
namespace {
std::string formatAgeSeconds(std::int64_t secs,
std::optional<std::chrono::system_clock::time_point> calendarAfterSixDays) {
using namespace std::chrono;
if (secs < 0) {
secs = 0;
}
if (secs < 60) {
return i18n::tr("time.relative.just-now");
}
if (secs < 3600) {
const long mins = secs / 60;
return i18n::trp("time.relative.minutes-ago", mins);
}
if (secs < 86400) {
const long hrs = secs / 3600;
return i18n::trp("time.relative.hours-ago", hrs);
}
if (secs < 7 * 86400) {
const long days = secs / 86400;
return i18n::trp("time.relative.days-ago", days);
}
if (calendarAfterSixDays.has_value()) {
const std::time_t rawTime = std::chrono::system_clock::to_time_t(*calendarAfterSixDays);
std::tm localTime{};
localtime_r(&rawTime, &localTime);
char buffer[32];
std::strftime(buffer, sizeof(buffer), "%b %e", &localTime);
return buffer;
}
const long days = static_cast<long>(secs / 86400);
return i18n::trp("time.relative.days-ago", days);
}
} // namespace
namespace {
bool shouldUseStrftimeCompat(std::string_view fmt) {
@@ -156,28 +193,13 @@ std::string formatFileTime(const std::filesystem::file_time_type& time) {
std::string formatTimeAgo(std::chrono::system_clock::time_point tp) {
using namespace std::chrono;
const auto secs = duration_cast<seconds>(system_clock::now() - tp).count();
return formatAgeSeconds(secs, tp);
}
if (secs < 60) {
return i18n::tr("time.relative.just-now");
}
if (secs < 3600) {
const long mins = secs / 60;
return i18n::trp("time.relative.minutes-ago", mins);
}
if (secs < 86400) {
const long hrs = secs / 3600;
return i18n::trp("time.relative.hours-ago", hrs);
}
if (secs < 7 * 86400) {
const long days = secs / 86400;
return i18n::trp("time.relative.days-ago", days);
}
const std::time_t rawTime = system_clock::to_time_t(tp);
std::tm localTime{};
localtime_r(&rawTime, &localTime);
char buffer[32];
std::strftime(buffer, sizeof(buffer), "%b %e", &localTime);
return buffer;
std::string formatElapsedSince(std::chrono::steady_clock::time_point since) {
using namespace std::chrono;
const auto secs = duration_cast<seconds>(steady_clock::now() - since).count();
return formatAgeSeconds(secs, std::nullopt);
}
std::string formatDuration(std::chrono::seconds duration) {
+3
View File
@@ -7,6 +7,9 @@
std::string formatTimeAgo(std::chrono::system_clock::time_point tp);
// Same wording as formatTimeAgo, but duration is computed from steady_clock (e.g. Notification::receivedTime).
[[nodiscard]] std::string formatElapsedSince(std::chrono::steady_clock::time_point since);
// Formats a duration as "{d}d {h}h {m}m" / "{h}h {m}m" / "{m}m" / "<1m".
[[nodiscard]] std::string formatDuration(std::chrono::seconds duration);
+15 -4
View File
@@ -17,6 +17,7 @@
#include <array>
#include <cctype>
#include <cmath>
#include <functional>
#include <memory>
#include <optional>
#include <string>
@@ -26,6 +27,7 @@ namespace {
ClipboardService* g_clipboard = nullptr;
Input::PasswordMaskStyle g_passwordMaskStyle = Input::PasswordMaskStyle::CircleFilled;
std::function<bool(std::uint32_t, std::uint32_t)> g_validateKeyMatcher;
std::optional<std::string> readClipboardText() {
if (g_clipboard == nullptr) {
@@ -332,6 +334,10 @@ void Input::setOnKeyEvent(std::function<bool(std::uint32_t, std::uint32_t)> call
void Input::setClipboardService(ClipboardService* clipboard) noexcept { g_clipboard = clipboard; }
void Input::setValidateKeyMatcher(std::function<bool(std::uint32_t, std::uint32_t)> matcher) noexcept {
g_validateKeyMatcher = std::move(matcher);
}
void Input::setPasswordMaskStyle(PasswordMaskStyle style) noexcept { g_passwordMaskStyle = style; }
void Input::selectAll() {
@@ -488,10 +494,15 @@ void Input::handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modi
return;
}
const bool validateMatch = g_validateKeyMatcher && g_validateKeyMatcher(sym, modifiers);
// Ignore keys that produce no text and aren't action keys we handle below
if (utf32 == 0 && !preedit && sym != XKB_KEY_BackSpace && sym != XKB_KEY_Delete && sym != XKB_KEY_Left &&
sym != XKB_KEY_Right && sym != XKB_KEY_Home && sym != XKB_KEY_End && sym != XKB_KEY_Return) {
return;
if (utf32 == 0 && !preedit) {
const bool navigationOrEdit = sym == XKB_KEY_BackSpace || sym == XKB_KEY_Delete || sym == XKB_KEY_Left ||
sym == XKB_KEY_Right || sym == XKB_KEY_Home || sym == XKB_KEY_End;
if (!navigationOrEdit && !validateMatch) {
return;
}
}
bool changed = false;
@@ -585,7 +596,7 @@ void Input::handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modi
if (!shift) {
m_selectionAnchor = m_cursorPos;
}
} else if (sym == XKB_KEY_Return) {
} else if (validateMatch) {
if (m_onSubmit) {
m_onSubmit(m_value);
}
+3
View File
@@ -7,6 +7,7 @@
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
#include <string_view>
@@ -45,6 +46,8 @@ public:
// Set once at application startup; all Input instances use this for Ctrl+C/X/V.
static void setClipboardService(ClipboardService* clipboard) noexcept;
/// Submit invokes onSubmit only when this matcher returns true (Application wires ConfigService validate keybinds).
static void setValidateKeyMatcher(std::function<bool(std::uint32_t sym, std::uint32_t modifiers)> matcher) noexcept;
static void setPasswordMaskStyle(PasswordMaskStyle style) noexcept;
void clearSelection();
+295 -45
View File
@@ -1,5 +1,7 @@
#include "ui/controls/label.h"
#include "render/animation/animation.h"
#include "render/animation/animation_manager.h"
#include "render/core/renderer.h"
#include "ui/palette.h"
#include "ui/style.h"
@@ -8,7 +10,13 @@
#include <cmath>
#include <memory>
Label::Label() {
namespace {
constexpr const char* kMarqueeGap = " ";
} // namespace
Label::Label() : InputArea() {
auto textNode = std::make_unique<TextNode>();
m_textNode = static_cast<TextNode*>(addChild(std::move(textNode)));
m_textNode->setFontSize(Style::fontSizeBody);
@@ -17,9 +25,11 @@ Label::Label() {
}
bool Label::setText(std::string_view text) {
if (m_textNode->text() == text)
if (m_plainText == text) {
return false;
m_textNode->setText(std::string(text));
}
m_plainText = std::string(text);
m_textNode->setText(m_plainText);
m_measureCached = false;
return true;
}
@@ -50,18 +60,24 @@ void Label::setMinWidth(float minWidth) {
}
void Label::setMaxWidth(float maxWidth) {
if (m_textNode->maxWidth() == maxWidth) {
if (m_userMaxWidth == maxWidth) {
return;
}
m_textNode->setMaxWidth(maxWidth);
m_userMaxWidth = maxWidth;
if (!m_autoScroll) {
m_textNode->setMaxWidth(maxWidth);
}
m_measureCached = false;
}
void Label::setMaxLines(int maxLines) {
if (m_textNode->maxLines() == maxLines) {
if (m_userMaxLines == maxLines) {
return;
}
m_textNode->setMaxLines(maxLines);
m_userMaxLines = maxLines;
if (!m_autoScroll) {
m_textNode->setMaxLines(maxLines);
}
m_measureCached = false;
}
@@ -73,13 +89,13 @@ void Label::setBold(bool bold) {
m_measureCached = false;
}
const std::string& Label::text() const noexcept { return m_textNode->text(); }
const std::string& Label::text() const noexcept { return m_plainText; }
float Label::fontSize() const noexcept { return m_textNode->fontSize(); }
const Color& Label::color() const noexcept { return m_textNode->color(); }
float Label::maxWidth() const noexcept { return m_textNode->maxWidth(); }
float Label::maxWidth() const noexcept { return m_userMaxWidth; }
bool Label::bold() const noexcept { return m_textNode->bold(); }
@@ -107,6 +123,195 @@ void Label::setShadow(const Color& color, float offsetX, float offsetY) {
void Label::clearShadow() { m_textNode->clearShadow(); }
void Label::setAutoScroll(bool enabled) {
if (m_autoScroll == enabled) {
return;
}
m_autoScroll = enabled;
stopScrollAnimations();
m_scrollOffset = 0.0f;
syncTextNodeConstraints();
m_measureCached = false;
syncHoverInteraction();
}
void Label::setAutoScrollOnlyWhenHovered(bool enabled) {
if (m_autoScrollHoverOnly == enabled) {
return;
}
m_autoScrollHoverOnly = enabled;
syncHoverInteraction();
restartScrollIfNeeded();
}
void Label::syncHoverInteraction() {
if (!m_autoScroll || !m_autoScrollHoverOnly) {
setOnEnter(nullptr);
setOnLeave(nullptr);
return;
}
setOnEnter([this](const PointerData&) { restartScrollIfNeeded(); });
setOnLeave([this]() { restartScrollIfNeeded(); });
}
void Label::setAutoScrollSpeed(float pixelsPerSecond) {
const float next = std::max(pixelsPerSecond, 1.0f);
if (m_scrollSpeedPxPerSec == next) {
return;
}
m_scrollSpeedPxPerSec = next;
if (!m_autoScroll) {
return;
}
stopScrollAnimations();
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
startMarqueeLoop();
}
void Label::syncTextNodeConstraints() {
if (m_autoScroll) {
m_textNode->setMaxWidth(0.0f);
m_textNode->setMaxLines(1);
} else {
m_textNode->setMaxWidth(m_userMaxWidth);
m_textNode->setMaxLines(m_userMaxLines);
}
}
void Label::applyScrollPosition() { m_textNode->setPosition(m_textBaseX - m_scrollOffset, m_baselineOffset); }
void Label::stopMarqueeAnimation() {
if (animationManager() != nullptr && m_marqueeAnimId != 0) {
animationManager()->cancel(m_marqueeAnimId);
}
m_marqueeAnimId = 0;
}
void Label::stopSnapAnimation() {
if (animationManager() != nullptr && m_snapAnimId != 0) {
animationManager()->cancel(m_snapAnimId);
}
m_snapAnimId = 0;
}
void Label::stopScrollAnimations() {
stopMarqueeAnimation();
stopSnapAnimation();
}
void Label::startSnapToZero() {
stopMarqueeAnimation();
if (m_scrollOffset <= 0.5f) {
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
return;
}
if (animationManager() == nullptr) {
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
return;
}
if (m_snapAnimId != 0) {
return;
}
const float from = m_scrollOffset;
const float rewindSpeed = m_scrollSpeedPxPerSec * 8.0f;
float durationMs = std::max(36.0f, (from / rewindSpeed) * 1000.0f);
durationMs = std::min(durationMs, 180.0f);
m_snapAnimId = animationManager()->animate(
from, 0.0f, durationMs, Easing::EaseOutCubic,
[this](float v) {
m_scrollOffset = v;
applyScrollPosition();
markPaintDirty();
},
[this]() {
m_snapAnimId = 0;
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
},
this);
}
void Label::startMarqueeLoop() {
if (!m_autoScroll || animationManager() == nullptr) {
return;
}
if (m_autoScrollHoverOnly && !hovered()) {
return;
}
const float viewportW = width();
if (viewportW <= 0.0f || m_fullTextWidth <= viewportW + 0.5f) {
return;
}
if (m_marqueeLoopPeriod <= 0.0f) {
return;
}
if (m_marqueeAnimId != 0) {
return;
}
stopSnapAnimation();
const float period = m_marqueeLoopPeriod;
const float durationMs = (period / m_scrollSpeedPxPerSec) * 1000.0f;
m_marqueeAnimId = animationManager()->animate(
0.0f, period, durationMs, Easing::Linear,
[this](float v) {
m_scrollOffset = v;
applyScrollPosition();
markPaintDirty();
},
[this]() {
m_marqueeAnimId = 0;
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
startMarqueeLoop();
},
this);
}
void Label::restartScrollIfNeeded() {
stopMarqueeAnimation();
const bool overflow = m_autoScroll && width() > 0.0f && m_fullTextWidth > width() + 0.5f;
if (!overflow) {
stopSnapAnimation();
m_scrollOffset = 0.0f;
setClipChildren(false);
m_textNode->setText(m_plainText);
applyScrollPosition();
markPaintDirty();
return;
}
setClipChildren(true);
const bool runMarquee = !m_autoScrollHoverOnly || hovered();
if (!runMarquee) {
if (m_scrollOffset > 0.5f) {
startSnapToZero();
} else {
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
}
return;
}
stopSnapAnimation();
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
startMarqueeLoop();
}
void Label::doLayout(Renderer& renderer) { measure(renderer); }
LayoutSize Label::doMeasure(Renderer& renderer, const LayoutConstraints& constraints) {
@@ -132,42 +337,42 @@ void Label::measure(Renderer& renderer) {
}
LayoutSize Label::measureWithConstraints(Renderer& renderer, const LayoutConstraints& constraints) {
const float configuredMaxWidth = m_textNode->maxWidth();
const float configuredMaxWidth = m_userMaxWidth;
float measureMaxWidth = configuredMaxWidth;
if (constraints.hasMaxWidth) {
measureMaxWidth =
configuredMaxWidth > 0.0f ? std::min(configuredMaxWidth, constraints.maxWidth) : constraints.maxWidth;
}
const int maxLines = m_textNode->maxLines();
const bool singleLine = (maxLines == 1) || (maxLines == 0 && configuredMaxWidth <= 0.0f &&
m_textNode->text().find('\n') == std::string::npos);
if (m_autoScroll) {
measureMaxWidth = 0.0f;
}
const int effectiveMaxLines = m_autoScroll ? 1 : m_userMaxLines;
const bool singleLine =
m_autoScroll || (effectiveMaxLines == 1) ||
(effectiveMaxLines == 0 && configuredMaxWidth <= 0.0f && m_plainText.find('\n') == std::string::npos);
const TextAlign align = m_textNode->textAlign();
if (m_measureCached && m_cachedText == m_textNode->text() && m_cachedFontSize == m_textNode->fontSize() &&
m_cachedBold == m_textNode->bold() && m_cachedMaxWidth == configuredMaxWidth && m_cachedMaxLines == maxLines &&
if (m_measureCached && m_cachedText == m_plainText && m_cachedFontSize == m_textNode->fontSize() &&
m_cachedBold == m_textNode->bold() && m_cachedMaxWidth == m_userMaxWidth && m_cachedMaxLines == m_userMaxLines &&
m_cachedMinWidth == m_minWidth && m_cachedConstraintMinWidth == constraints.minWidth &&
m_cachedConstraintMaxWidth == constraints.maxWidth && m_cachedHasConstraintMaxWidth == constraints.hasMaxWidth &&
m_cachedTextAlign == align && m_cachedStableBaseline == m_stableBaseline) {
m_cachedTextAlign == align && m_cachedStableBaseline == m_stableBaseline && m_cachedAutoScroll == m_autoScroll) {
return LayoutSize{.width = width(), .height = height()};
}
auto metrics = renderer.measureText(m_textNode->text(), m_textNode->fontSize(), m_textNode->bold(), measureMaxWidth,
maxLines, align);
syncTextNodeConstraints();
auto metrics = renderer.measureText(m_plainText, m_textNode->fontSize(), m_textNode->bold(), measureMaxWidth,
effectiveMaxLines, align);
auto refMetrics = renderer.measureText("A", m_textNode->fontSize(), m_textNode->bold());
const float measuredWidth = measureMaxWidth > 0.0f ? std::min(metrics.width, measureMaxWidth) : metrics.width;
m_fullTextWidth = m_autoScroll ? measuredWidth : 0.0f;
const bool hasAssignedWidth = constraints.hasExactWidth();
const float assignedWidth = constraints.maxWidth;
const float refHeight = refMetrics.bottom - refMetrics.top;
const float actualHeight = metrics.bottom - metrics.top;
const float inkHeight = std::max(0.0f, metrics.inkBottom - metrics.inkTop);
// Keep single-line labels on the same reference height as glyphs, but center
// the visible text ink within that height so digits and symbols do not read
// optically low beside icons.
if (singleLine && inkHeight > 0.0f) {
// Stable-baseline labels center on the caps reference ("A") instead of the
// current text's ink. That keeps caps at a fixed y across text changes
// (e.g. a clock cycling "Mar" → "Apr") AND matches the y-position used by
// sibling dynamic-mode labels whose text happens to be caps-only (e.g. a
// weather capsule reading "15°C"), so they align horizontally.
float inkTopForCentering = metrics.inkTop;
float inkHeightForCentering = inkHeight;
if (m_stableBaseline) {
@@ -177,48 +382,93 @@ LayoutSize Label::measureWithConstraints(Renderer& renderer, const LayoutConstra
inkHeightForCentering = capInkHeight;
}
}
// Round height BEFORE computing the ink-centering offset so the ink center
// lands at the geometric center of the rounded (visible) box, not the
// unrounded refHeight — otherwise callers that center the label box inside
// a parent see the ink offset by up to 0.5px.
const float height = std::round(std::max(refHeight, inkHeight));
m_baselineOffset = -inkTopForCentering + (height - inkHeightForCentering) * 0.5f;
const float finalWidth =
hasAssignedWidth ? std::max(assignedWidth, m_minWidth) : std::max(measuredWidth, m_minWidth);
float finalWidth = 0.0f;
if (m_autoScroll) {
float boxW = m_fullTextWidth;
if (hasAssignedWidth) {
boxW = assignedWidth;
} else {
if (constraints.hasMaxWidth) {
boxW = std::min(boxW, constraints.maxWidth);
}
if (m_userMaxWidth > 0.0f) {
boxW = std::min(boxW, m_userMaxWidth);
}
}
finalWidth = std::max(boxW, m_minWidth);
} else {
finalWidth = hasAssignedWidth ? std::max(assignedWidth, m_minWidth) : std::max(measuredWidth, m_minWidth);
}
setSize(std::round(finalWidth), height);
} else {
m_baselineOffset = -std::min(refMetrics.top, metrics.top);
const float inkBottom = m_baselineOffset + metrics.bottom;
const float height = std::max({refHeight, actualHeight, inkBottom});
const float finalWidth =
hasAssignedWidth ? std::max(assignedWidth, m_minWidth) : std::max(measuredWidth, m_minWidth);
float finalWidth = 0.0f;
if (m_autoScroll) {
float boxW = m_fullTextWidth;
if (hasAssignedWidth) {
boxW = assignedWidth;
} else {
if (constraints.hasMaxWidth) {
boxW = std::min(boxW, constraints.maxWidth);
}
if (m_userMaxWidth > 0.0f) {
boxW = std::min(boxW, m_userMaxWidth);
}
}
finalWidth = std::max(boxW, m_minWidth);
} else {
finalWidth = hasAssignedWidth ? std::max(assignedWidth, m_minWidth) : std::max(measuredWidth, m_minWidth);
}
setSize(std::round(finalWidth), std::round(height));
}
if (width() < m_minWidth) {
setSize(std::round(m_minWidth), height());
}
const float layoutWidth = width();
const bool overflow = m_autoScroll && m_fullTextWidth > layoutWidth + 0.5f;
const float alignWidth = m_autoScroll ? m_fullTextWidth : measuredWidth;
float textX = 0.0f;
const float finalWidth = width();
if (align == TextAlign::Center) {
textX = (finalWidth - measuredWidth) * 0.5f;
} else if (align == TextAlign::End) {
textX = finalWidth - measuredWidth;
if (!overflow) {
if (align == TextAlign::Center) {
textX = (layoutWidth - alignWidth) * 0.5f;
} else if (align == TextAlign::End) {
textX = layoutWidth - alignWidth;
}
}
m_textBaseX = overflow ? 0.0f : textX;
if (!overflow) {
m_scrollOffset = 0.0f;
}
// Keep subpixel baseline/text offsets here; cairo text rendering performs
// a single final snap in device-pixel space after full world transform.
m_textNode->setPosition(textX, m_baselineOffset);
m_cachedText = m_textNode->text();
if (overflow && m_autoScroll) {
auto gapMetrics = renderer.measureText(kMarqueeGap, m_textNode->fontSize(), m_textNode->bold(), 0.0f, 1, align);
m_marqueeLoopPeriod = m_fullTextWidth + gapMetrics.width;
m_textNode->setText(m_plainText + kMarqueeGap + m_plainText);
} else {
m_marqueeLoopPeriod = 0.0f;
m_textNode->setText(m_plainText);
}
applyScrollPosition();
m_cachedText = m_plainText;
m_cachedFontSize = m_textNode->fontSize();
m_cachedBold = m_textNode->bold();
m_cachedMaxWidth = configuredMaxWidth;
m_cachedMaxLines = maxLines;
m_cachedMaxWidth = m_userMaxWidth;
m_cachedMaxLines = m_userMaxLines;
m_cachedMinWidth = m_minWidth;
m_cachedConstraintMinWidth = constraints.minWidth;
m_cachedConstraintMaxWidth = constraints.maxWidth;
m_cachedHasConstraintMaxWidth = constraints.hasMaxWidth;
m_cachedTextAlign = align;
m_cachedStableBaseline = m_stableBaseline;
m_cachedAutoScroll = m_autoScroll;
m_measureCached = true;
restartScrollIfNeeded();
return LayoutSize{.width = width(), .height = height()};
}
+38 -2
View File
@@ -1,17 +1,18 @@
#pragma once
#include "render/core/color.h"
#include "render/scene/node.h"
#include "render/scene/input_area.h"
#include "render/scene/text_node.h"
#include "ui/palette.h"
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
class Renderer;
class Label : public Node {
class Label : public InputArea {
public:
Label();
@@ -33,6 +34,16 @@ public:
void setStableBaseline(bool stable);
void setShadow(const Color& color, float offsetX, float offsetY);
void clearShadow();
// Single-line horizontal marquee when the line is wider than the laid-out width.
// Constrain width with parent layout and/or setMaxWidth() — Flex ignores preset setSize().
// Requires an AnimationManager on the scene (via setAnimationManager).
void setAutoScroll(bool enabled);
void setAutoScrollSpeed(float pixelsPerSecond);
// When true (with auto-scroll), marquee runs only while the pointer is over the label.
void setAutoScrollOnlyWhenHovered(bool enabled);
[[nodiscard]] bool autoScroll() const noexcept { return m_autoScroll; }
[[nodiscard]] float autoScrollSpeed() const noexcept { return m_scrollSpeedPxPerSec; }
[[nodiscard]] bool autoScrollOnlyWhenHovered() const noexcept { return m_autoScrollHoverOnly; }
[[nodiscard]] const std::string& text() const noexcept;
[[nodiscard]] float fontSize() const noexcept;
@@ -52,6 +63,15 @@ private:
void doArrange(Renderer& renderer, const LayoutRect& rect) override;
void applyPalette();
LayoutSize measureWithConstraints(Renderer& renderer, const LayoutConstraints& constraints);
void syncTextNodeConstraints();
void restartScrollIfNeeded();
void stopMarqueeAnimation();
void stopSnapAnimation();
void stopScrollAnimations();
void startMarqueeLoop();
void startSnapToZero();
void applyScrollPosition();
void syncHoverInteraction();
TextNode* m_textNode = nullptr;
float m_minWidth = 0.0f;
@@ -59,6 +79,9 @@ private:
ColorSpec m_color = colorSpecFromRole(ColorRole::OnSurface);
Signal<>::ScopedConnection m_paletteConn;
// User-visible text (wire text may duplicate for seamless marquee).
std::string m_plainText;
// Memoized measure() inputs — lets repeated layout passes with identical
// text skip the Pango/fontconfig path entirely.
std::string m_cachedText;
@@ -71,7 +94,20 @@ private:
TextAlign m_cachedTextAlign = TextAlign::Start;
bool m_cachedBold = false;
bool m_cachedStableBaseline = false;
bool m_cachedAutoScroll = false;
bool m_cachedHasConstraintMaxWidth = false;
bool m_measureCached = false;
bool m_stableBaseline = false;
float m_userMaxWidth = 0.0f;
int m_userMaxLines = 0;
bool m_autoScroll = false;
bool m_autoScrollHoverOnly = false;
float m_scrollSpeedPxPerSec = 48.0f;
float m_scrollOffset = 0.0f;
float m_fullTextWidth = 0.0f;
float m_marqueeLoopPeriod = 0.0f;
float m_textBaseX = 0.0f;
std::uint32_t m_marqueeAnimId = 0;
std::uint32_t m_snapAnimId = 0;
};
+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;
};
+5 -15
View File
@@ -98,7 +98,6 @@ bool ColorPickerDialogPopup::onPointerEvent(const PointerEvent& event) {
}
const bool captured = m_inputDispatcher.pointerCaptured();
wl_surface* const eventSurface = resolveEventSurface(event);
float localX = 0.0f;
float localY = 0.0f;
const bool mapped = mapPointerEvent(event, localX, localY);
@@ -151,14 +150,6 @@ bool ColorPickerDialogPopup::onPointerEvent(const PointerEvent& event) {
m_inputDispatcher.pointerMotion(localX, localY, event.serial);
}
m_inputDispatcher.pointerButton(localX, localY, event.button, event.state == 1);
if (event.state == 1 && !captured) {
if (m_inputDispatcher.pointerCaptured()) {
m_captureCoordinateSpace =
ownsSurface(eventSurface) ? CaptureCoordinateSpace::PopupLocal : CaptureCoordinateSpace::ParentMapped;
}
} else if (event.state != 1 && !m_inputDispatcher.pointerCaptured()) {
m_captureCoordinateSpace = CaptureCoordinateSpace::None;
}
break;
case PointerEvent::Type::Axis:
if (captured) {
@@ -334,7 +325,6 @@ void ColorPickerDialogPopup::destroyPopup() {
}
m_pointerInside = false;
m_parentSurface = nullptr;
m_captureCoordinateSpace = CaptureCoordinateSpace::None;
m_inputDispatcher.setSceneRoot(nullptr);
m_sheet = nullptr;
m_bgNode = nullptr;
@@ -353,18 +343,18 @@ bool ColorPickerDialogPopup::mapPointerEvent(const PointerEvent& event, float& l
return false;
}
// During implicit grab, motion may be delivered with the parent surface as the event
// target while coordinates are still relative to that surface — not popup-local.
if (m_inputDispatcher.pointerCaptured() && event.type != PointerEvent::Type::Leave) {
switch (m_captureCoordinateSpace) {
case CaptureCoordinateSpace::PopupLocal:
if (ownsSurface(eventSurface)) {
localX = static_cast<float>(event.sx);
localY = static_cast<float>(event.sy);
return true;
case CaptureCoordinateSpace::ParentMapped:
}
if (eventSurface == m_parentSurface) {
localX = static_cast<float>(event.sx) - static_cast<float>(m_surface->configuredX());
localY = static_cast<float>(event.sy) - static_cast<float>(m_surface->configuredY());
return true;
case CaptureCoordinateSpace::None:
break;
}
}
@@ -33,12 +33,6 @@ public:
[[nodiscard]] wl_surface* wlSurface() const noexcept;
private:
enum class CaptureCoordinateSpace : std::uint8_t {
None,
PopupLocal,
ParentMapped,
};
void accept(const Color& result);
void cancel();
void prepareFrame(bool needsUpdate, bool needsLayout);
@@ -66,6 +60,5 @@ private:
InputDispatcher m_inputDispatcher;
bool m_attachedToHost = false;
wl_surface* m_parentSurface = nullptr;
CaptureCoordinateSpace m_captureCoordinateSpace = CaptureCoordinateSpace::None;
bool m_pointerInside = false;
};
+5 -15
View File
@@ -99,7 +99,6 @@ bool FileDialogPopup::onPointerEvent(const PointerEvent& event) {
}
const bool captured = m_inputDispatcher.pointerCaptured();
wl_surface* const eventSurface = resolveEventSurface(event);
float localX = 0.0f;
float localY = 0.0f;
const bool mapped = mapPointerEvent(event, localX, localY);
@@ -152,14 +151,6 @@ bool FileDialogPopup::onPointerEvent(const PointerEvent& event) {
m_inputDispatcher.pointerMotion(localX, localY, event.serial);
}
m_inputDispatcher.pointerButton(localX, localY, event.button, event.state == 1);
if (event.state == 1 && !captured) {
if (m_inputDispatcher.pointerCaptured()) {
m_captureCoordinateSpace =
ownsSurface(eventSurface) ? CaptureCoordinateSpace::PopupLocal : CaptureCoordinateSpace::ParentMapped;
}
} else if (event.state != 1 && !m_inputDispatcher.pointerCaptured()) {
m_captureCoordinateSpace = CaptureCoordinateSpace::None;
}
break;
case PointerEvent::Type::Axis:
if (captured) {
@@ -364,7 +355,6 @@ void FileDialogPopup::destroyPopup() {
}
m_pointerInside = false;
m_parentSurface = nullptr;
m_captureCoordinateSpace = CaptureCoordinateSpace::None;
m_inputDispatcher.setSceneRoot(nullptr);
if (m_dialog != nullptr) {
m_dialog->onClose();
@@ -386,18 +376,18 @@ bool FileDialogPopup::mapPointerEvent(const PointerEvent& event, float& localX,
return false;
}
// During implicit grab, motion may be delivered with the parent surface as the event
// target while coordinates are still relative to that surface — not popup-local.
if (m_inputDispatcher.pointerCaptured() && event.type != PointerEvent::Type::Leave) {
switch (m_captureCoordinateSpace) {
case CaptureCoordinateSpace::PopupLocal:
if (ownsSurface(eventSurface)) {
localX = static_cast<float>(event.sx);
localY = static_cast<float>(event.sy);
return true;
case CaptureCoordinateSpace::ParentMapped:
}
if (eventSurface == m_parentSurface) {
localX = static_cast<float>(event.sx) - static_cast<float>(m_surface->configuredX());
localY = static_cast<float>(event.sy) - static_cast<float>(m_surface->configuredY());
return true;
case CaptureCoordinateSpace::None:
break;
}
}
-7
View File
@@ -42,12 +42,6 @@ public:
void cancel() override;
private:
enum class CaptureCoordinateSpace : std::uint8_t {
None,
PopupLocal,
ParentMapped,
};
[[nodiscard]] wl_surface* resolveEventSurface(const PointerEvent& event) const noexcept;
[[nodiscard]] std::optional<LayerPopupParentContext> resolveParentContext() const;
void prepareFrame(bool needsUpdate, bool needsLayout);
@@ -74,6 +68,5 @@ private:
std::unique_ptr<FileDialogView> m_dialog;
bool m_attachedToHost = false;
wl_surface* m_parentSurface = nullptr;
CaptureCoordinateSpace m_captureCoordinateSpace = CaptureCoordinateSpace::None;
bool m_pointerInside = false;
};
+17
View File
@@ -447,6 +447,23 @@ bool ClipboardService::promoteEntry(std::size_t index) {
return true;
}
bool ClipboardService::removeHistoryEntry(std::size_t index) {
if (index >= m_history.size()) {
return false;
}
const std::size_t removedBytes = m_history[index].byteSize;
m_history.erase(m_history.begin() + static_cast<std::ptrdiff_t>(index));
if (m_historyBytes >= removedBytes) {
m_historyBytes -= removedBytes;
} else {
m_historyBytes = 0;
}
++m_changeSerial;
persistHistory();
notifyChanged();
return true;
}
void ClipboardService::clearHistory() {
if (m_history.empty()) {
return;
+1
View File
@@ -76,6 +76,7 @@ public:
bool copyText(std::string text);
bool copyEntry(const ClipboardEntry& entry);
bool promoteEntry(std::size_t index);
bool removeHistoryEntry(std::size_t index);
void clearHistory();
void setChangeCallback(ChangeCallback callback);
void dispatchReadEvents(short revents);