mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
Merge branch 'v5' of github.com:noctalia-dev/noctalia-shell into v5
This commit is contained in:
@@ -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)",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user