feat: expire progress bar on popup notifs

This commit is contained in:
2026-11-09 21:39:21 +01:00
parent e49ecabf62
commit 37e0429563
5 changed files with 324 additions and 153 deletions
+205 -132
View File
@@ -38,21 +38,14 @@ namespace {
constexpr int MAX_ROWS = 500;
constexpr int MAX_PER_GROUP = 50;
constexpr qint64 MAX_AGE_MS = 30LL * 24 * 60 * 60 * 1000;
constexpr int TICK_MS = 250;
constexpr int TICK_MS = 100;
constexpr int MAX_IMAGE_DIM = 512;
// Memoized icon theme file lookups (hits and misses) per theme configuration.
constexpr int MAX_ICON_PATHS = 256;
constexpr qint64 PRUNE_INTERVAL_MS = 4LL * 60 * 60 * 1000;
constexpr qint64 TIME_STR_REFRESH_MS = 2000;
// Coalesces content updates (live replaces_id edits, popup state) into one
// transaction instead of one UPDATE per changed field.
constexpr qint64 MAX_EXPIRE_MS = 5LL * 60 * 1000;
constexpr int FLUSH_MS = 300;
// Quickshell serves inline notification pictures through image://qsimage/
// (and qspixmap) handles that belong to the live notification, not to the
// shell: once that notification is replaced or dismissed, the handle is gone
// and the url fails to resolve. Such a source is only ever useful as the raw
// url long enough to read the bytes, so the UI must never see it.
bool transientImageSource(const QString& source) {
if (source.isEmpty()) {
return false;
@@ -64,8 +57,6 @@ bool transientImageSource(const QString& source) {
url.host() == QLatin1String("qspixmap"));
}
// The url the UI should use: the cached blob when there is one, and the raw
// source only while that source keeps resolving.
QString displayImage(const QString& source, const QString& digest) {
if (!digest.isEmpty()) {
return QStringLiteral("image://notifimg/") + digest;
@@ -74,14 +65,9 @@ QString displayImage(const QString& source, const QString& digest) {
return transientImageSource(source) ? QString() : source;
}
// Icon theme state, captured on the GUI thread. `QIcon::themeName()`,
// `fallbackThemeName()` and `themeSearchPaths()` touch GUI-thread only state,
// but the strings they return are safe to hand to a worker.
struct IconTheme {
QStringList searchPaths;
QStringList themes;
// Changes when the theme or the search paths change, so the path cache
// below can be dropped after a theme switch.
QString signature;
};
@@ -104,21 +90,11 @@ IconTheme iconTheme() {
return theme;
}
// Resolve an icon name to a file the theme actually ships, so notification
// images that only name an icon can still be cached. KF6's KIconLoader is not
// thread-safe (it crashed the shell when the icon provider was called from a
// worker) and QIcon painting is GUI-thread only, so this only builds candidate
// paths and stats them - which makes it worker-thread safe when the theme
// strings come from iconTheme(). Hits and misses are both memoized: a sender
// repeats the same icon name, and a name the theme lacks costs thousands of
// stat calls to discover.
QString iconFileForName(const QString& name, const IconTheme& theme) {
if (name.isEmpty()) {
return {};
}
// Stat results only change when a theme is installed or removed, which the
// signature covers; the cap just keeps a stream of odd names bounded.
static std::mutex cacheMutex;
static QHash<QString, QString> pathCache;
static QString cacheSignature;
@@ -171,7 +147,6 @@ QString iconFileForName(const QString& name, const IconTheme& theme) {
const QString suffix =
candidateName + "." + extension;
// hicolor: <size>/<context>/<ext>/<name>.<ext>
const QString canonical = themeDir.filePath(
size + "/" + context + "/" + extension +
"/" + suffix);
@@ -179,15 +154,12 @@ QString iconFileForName(const QString& name, const IconTheme& theme) {
return canonical;
}
// hicolor and Papirus both also ship
// <size>/<context>/<name>.<ext>
const QString sized = themeDir.filePath(
size + "/" + context + "/" + suffix);
if (QFileInfo::exists(sized)) {
return sized;
}
// themes ordering size under context
const QString nested = themeDir.filePath(
context + "/" + size + "/" + suffix);
if (QFileInfo::exists(nested)) {
@@ -197,7 +169,6 @@ QString iconFileForName(const QString& name, const IconTheme& theme) {
}
}
// theme without size directories: <context>/<name>.<ext>
for (const auto& context : contexts) {
for (const auto& extension : extensions) {
const QString plain = themeDir.filePath(
@@ -420,10 +391,6 @@ void NotificationRow::setPopup(bool popup) {
}
m_popup = popup;
m_popupPaused = false;
m_deadline =
popup ? QDateTime::currentMSecsSinceEpoch() + m_store->popupTimeout()
: 0;
emit popupChanged();
if (m_store != nullptr) {
@@ -473,9 +440,6 @@ void NotificationRow::lock(QObject* item) {
}
m_locks.insert(item);
// Goes through unlock() so releasing the last lock on an already-closed
// row finishes the removal the close started. The destroyed signal
// carries the dying object, which is the lock holder.
QObject::connect(item, &QObject::destroyed, this, [this](QObject* obj) {
unlock(obj);
});
@@ -500,15 +464,17 @@ void NotificationRow::unlock(QObject* item) {
}
void NotificationRow::pausePopup() {
m_popupPaused = true;
if (m_popupGroup != nullptr) {
m_popupGroup->holdExpiry();
}
}
void NotificationRow::resumePopup() {
m_popupPaused = false;
if (m_popupGroup != nullptr) {
m_popupGroup->releaseExpiry();
}
}
// The v2 notification UI uses caelestia's naming; these are thin aliases of the
// existing behaviour so both spellings work.
void NotificationRow::dismiss() {
close();
}
@@ -535,8 +501,6 @@ void NotificationRow::unref(QObject* item) {
unlock(item);
}
// True when path is this row's image, in either its raw or cached form, so the
// UI can tell "the same picture changed url" from "a different picture".
bool NotificationRow::isSameImage(const QString& path) const {
return path == m_image || path == m_imageSource;
}
@@ -545,11 +509,6 @@ bool NotificationRow::isSameAppIcon(const QString& path) const {
return path == m_appIcon;
}
bool NotificationRow::expired(qint64 now) const {
return m_popup && !m_popupPaused && !m_closed && m_deadline > 0 &&
now >= m_deadline;
}
void NotificationRow::refreshTimeStr() {
const qint64 diff = QDateTime::currentMSecsSinceEpoch() - m_timeMs;
const qint64 m = diff / 60000;
@@ -595,6 +554,26 @@ int NotificationGroup::count() const {
return m_count;
}
bool NotificationGroup::expireActive() const {
return m_expireActive;
}
bool NotificationGroup::expirePaused() const {
return m_expirePaused;
}
qreal NotificationGroup::expireProgress() const {
return m_expireProgress;
}
int NotificationGroup::expireRemaining() const {
return m_expireRemaining;
}
int NotificationGroup::expireTimeout() const {
return m_expireTimeout;
}
QString NotificationGroup::firstSummary() const {
return m_firstSummary;
}
@@ -636,11 +615,6 @@ void NotificationGroup::removeRow(NotificationRow* row) {
QObject::disconnect(row, nullptr, this, nullptr);
emit notifsChanged();
// Must not drop the row from m_active here: refreshActive() only emits
// activeNotifsChanged when the list it rebuilds differs from the stored
// one, and a silently pre-edited m_active is equal to the rebuild. The
// popup stack reads activeNotifs, so skipping the emit left a closed row
// occupying its slot - an invisible gap other entries did not move into.
refreshActive();
sync();
}
@@ -708,8 +682,109 @@ void NotificationGroup::sync() {
}
}
// Delegates compare their own shown url against the group's current source row,
// so a row swapping raw source <-> cached blob does not re-trigger the fade.
void NotificationGroup::resetExpiry(int timeout) {
const qint64 now = QDateTime::currentMSecsSinceEpoch();
if (timeout != m_expireTimeout) {
m_expireTimeout = timeout;
emit expireTimeoutChanged();
}
if (timeout <= 0) {
m_expireDeadline = 0;
m_expireHeld = 0;
if (m_expirePaused) {
m_expirePaused = false;
emit expirePausedChanged();
}
} else if (m_expirePaused) {
m_expireHeld = timeout;
} else {
m_expireDeadline = now + timeout;
}
refreshExpiry(now);
emit expireTimerRestarted();
}
void NotificationGroup::holdExpiry() {
if (m_expirePaused || m_expireDeadline <= 0) {
return;
}
const qint64 now = QDateTime::currentMSecsSinceEpoch();
m_expireHeld = (std::max)(qint64(0), m_expireDeadline - now);
m_expireDeadline = 0;
m_expirePaused = true;
emit expirePausedChanged();
refreshExpiry(now);
}
void NotificationGroup::releaseExpiry() {
if (!m_expirePaused) {
return;
}
const qint64 now = QDateTime::currentMSecsSinceEpoch();
m_expireDeadline = now + m_expireHeld;
m_expireHeld = 0;
m_expirePaused = false;
emit expirePausedChanged();
refreshExpiry(now);
}
void NotificationGroup::clearExpiry() {
m_expireDeadline = 0;
m_expireHeld = 0;
if (m_expirePaused) {
m_expirePaused = false;
emit expirePausedChanged();
}
refreshExpiry(QDateTime::currentMSecsSinceEpoch());
}
bool NotificationGroup::expired(qint64 now) const {
return m_expireDeadline > 0 && !m_expirePaused && now >= m_expireDeadline;
}
void NotificationGroup::refreshExpiry(qint64 now) {
qint64 remaining = 0;
if (m_expirePaused) {
remaining = m_expireHeld;
} else if (m_expireDeadline > 0) {
remaining = (std::max)(qint64(0), m_expireDeadline - now);
}
if (remaining != m_expireRemaining) {
m_expireRemaining = static_cast<int>(remaining);
emit expireRemainingChanged();
}
const qreal progress =
m_expireTimeout > 0
? (std::min)(1.0, static_cast<qreal>(remaining) / m_expireTimeout)
: 0;
if (!qFuzzyCompare(progress, m_expireProgress)) {
m_expireProgress = progress;
emit expireProgressChanged();
}
setExpireActive(m_expireDeadline > 0 || m_expirePaused);
}
void NotificationGroup::setExpireActive(bool active) {
if (active != m_expireActive) {
m_expireActive = active;
emit expireActiveChanged();
}
}
bool NotificationGroup::isSameImage(const QString& path) const {
return m_imageSource != nullptr && m_imageSource->isSameImage(path);
}
@@ -721,8 +796,6 @@ bool NotificationGroup::isSameAppIcon(const QString& path) const {
void NotificationGroup::lock(QObject* item) {
if (item != nullptr && !m_locks.contains(item)) {
m_locks.insert(item);
// No Qt::UniqueConnection here: it requires a member-function pointer,
// and the contains() check above already keeps this one-shot per item.
QObject::connect(
item, &QObject::destroyed, this, [this, item](QObject*) {
unlock(item);
@@ -737,8 +810,6 @@ void NotificationGroup::unlock(QObject* item) {
}
}
// Called by the store once the group has no rows left. A delegate may still be
// animating it out, so the delete waits for its last unlock.
void NotificationGroup::release() {
m_releasing = true;
if (m_locks.isEmpty()) {
@@ -758,9 +829,6 @@ NotificationStore::NotificationStore(QObject* parent)
m_flush->setInterval(FLUSH_MS);
QObject::connect(m_flush, &QTimer::timeout, this, &NotificationStore::flush);
// Config-backed settings, same pattern as TickingService: read the
// config node directly and update live on its change signals. QML may
// still write these properties; the next config change re-applies.
auto* notifs = config::ConfigSingleton::instance()->notifs();
applyNotifConfig(notifs);
QObject::connect(
@@ -818,6 +886,12 @@ void NotificationStore::setPopupTimeout(int timeout) {
m_popupTimeout = timeout;
emit popupTimeoutChanged();
if (m_autoExpire) {
for (auto* group : std::as_const(m_popupGroups)) {
group->resetExpiry(groupExpireTimeoutMs(group));
}
}
}
bool NotificationStore::autoExpire() const {
@@ -831,6 +905,14 @@ void NotificationStore::setAutoExpire(bool autoExpire) {
m_autoExpire = autoExpire;
emit autoExpireChanged();
for (auto* group : std::as_const(m_popupGroups)) {
if (autoExpire) {
group->resetExpiry(groupExpireTimeoutMs(group));
} else {
group->clearExpiry();
}
}
}
void NotificationStore::applyNotifConfig(const config::Notifs* notifs) {
@@ -881,9 +963,6 @@ NotificationRow* NotificationStore::insert(
attachNotification(row, wrapped, notification);
row->m_popup = popup;
if (popup) {
row->m_deadline = QDateTime::currentMSecsSinceEpoch() + m_popupTimeout;
}
insertDbRow(row);
startImageCache(row);
@@ -929,8 +1008,6 @@ void NotificationStore::clear() {
}
}
// The bulk delete above already removed every DB row; the per-row
// cleanup would just re-delete no-ops, so batch it instead.
m_clearing = true;
const auto rows = m_rows;
for (auto* row : rows) {
@@ -953,6 +1030,11 @@ void NotificationStore::copyFields(NotificationRow* row, QObject* notification)
row->m_resident = notification->property("resident").toBool();
row->m_hasActionIcons = notification->property("hasActionIcons").toBool();
row->m_urgency = notification->property("urgency").toInt();
const auto expireTimeout = notification->property("expireTimeout");
row->m_expireTimeoutMs = expireTimeout.isValid()
? static_cast<qint64>(expireTimeout.toDouble())
: -1;
}
void NotificationStore::attachNotification(
@@ -980,10 +1062,6 @@ void NotificationStore::attachNotification(
rowGuard->m_store->handleClosed(rowGuard);
});
// Quickshell applies replaces_id updates to the object we already
// hold; mirror those changes onto the row (in UI and in the DB).
// Connected straight to the slot (not through an intermediate
// signal) so sender() in handleLiveUpdate is the Notification.
static const QMetaMethod slot = [] {
const QMetaObject& storeMeta = NotificationStore::staticMetaObject;
return storeMeta.method(
@@ -1000,6 +1078,7 @@ void NotificationStore::attachNotification(
"hasActionIconsChanged",
"actionsChanged",
"hintsChanged",
"expireTimeoutChanged",
};
const auto* meta = notification->metaObject();
@@ -1040,6 +1119,11 @@ void NotificationStore::handleLiveUpdate() {
const bool hasActionIcons = it.key()->property("hasActionIcons").toBool();
const int urgency = it.key()->property("urgency").toInt();
const auto expireTimeout = it.key()->property("expireTimeout");
row->m_expireTimeoutMs = expireTimeout.isValid()
? static_cast<qint64>(expireTimeout.toDouble())
: -1;
const bool imageChanged = imageSource != row->m_imageSource;
if (summary != row->m_summary) {
@@ -1095,12 +1179,6 @@ void NotificationStore::handleLiveUpdate() {
startImageCache(row);
}
// New content deserves a full look: restart the popup countdown.
if (row->m_popup) {
row->m_deadline = QDateTime::currentMSecsSinceEpoch() + m_popupTimeout;
}
// The row may have moved to another app name (replaces_id can change it).
if (row->m_group != nullptr && row->m_group->m_appName != row->m_appName) {
removeGroupRow(row);
addGroup(row);
@@ -1111,6 +1189,10 @@ void NotificationStore::handleLiveUpdate() {
}
}
if (row->m_popupGroup != nullptr) {
touchExpiry(row);
}
markDirty(row);
}
@@ -1199,7 +1281,6 @@ NotificationRow* NotificationStore::rowForNotificationId(int id) const {
}
void NotificationStore::handleClosed(NotificationRow* row) {
// During clear() the bulk DELETE already removed every row.
if (m_clearing) {
removeRow(row);
return;
@@ -1221,9 +1302,6 @@ void NotificationStore::handleClosed(NotificationRow* row) {
row->m_group->sync();
}
// A closed row leaves the store immediately, so its group and the sidebar
// order update at once; a delegate that still holds it only delays the
// delete, never the removal.
removeRow(row);
}
@@ -1236,14 +1314,10 @@ void NotificationStore::removeRow(NotificationRow* row) {
removeGroupRow(row);
m_dirty.remove(row);
// The row is gone; drop any live-notification mapping to it so
// handleLiveUpdate can never resolve a freed pointer.
erase_if(m_liveRows, [row](std::pair<const QObject*, NotificationRow*> e) {
return e.second == row;
});
// A delegate animating this row out still holds it; unlock() deletes once
// the last lock is gone.
if (row->m_locks.isEmpty()) {
row->deleteLater();
} else {
@@ -1302,7 +1376,6 @@ void NotificationStore::addGroup(NotificationRow* row) {
m_groups.prepend(group);
emit groupsChanged();
} else {
// Most-recently-active group first, matching the sidebar order.
const qsizetype index = m_groups.indexOf(group);
if (index > 0) {
m_groups.move(index, 0);
@@ -1333,8 +1406,6 @@ void NotificationStore::removeGroupRow(NotificationRow* row) {
emit groupsChanged();
}
// Popup groups mirror m_groups over the rows currently showing as popups, so
// the popup stack can render one card per app instead of one per notification.
void NotificationStore::addPopupGroup(NotificationRow* row) {
if (row->m_popupGroup != nullptr) {
return;
@@ -1356,9 +1427,19 @@ void NotificationStore::addPopupGroup(NotificationRow* row) {
}
row->m_popupGroup = group;
touchExpiry(row);
group->addRow(row);
}
void NotificationStore::expireGroup(NotificationGroup* group) {
const auto rows = group->m_notifs; // setPopup() mutates the group's list
for (auto* row : rows) {
row->setPopup(false);
}
}
void NotificationStore::removePopupGroupRow(NotificationRow* row) {
auto* group = row->m_popupGroup;
if (group == nullptr) {
@@ -1378,6 +1459,32 @@ void NotificationStore::removePopupGroupRow(NotificationRow* row) {
emit popupGroupsChanged();
}
int NotificationStore::expireTimeoutMs(const NotificationRow* row) const {
if (row->m_expireTimeoutMs == 0) {
return 0;
}
if (row->m_expireTimeoutMs < 0) {
return m_popupTimeout;
}
return static_cast<int>((std::min)(row->m_expireTimeoutMs, MAX_EXPIRE_MS));
}
int NotificationStore::groupExpireTimeoutMs(
const NotificationGroup* group) const {
const auto* newest = group->m_notifs.value(0);
return newest != nullptr ? expireTimeoutMs(newest) : m_popupTimeout;
}
void NotificationStore::touchExpiry(NotificationRow* row) {
if (!m_autoExpire || row->m_popupGroup == nullptr) {
return;
}
row->m_popupGroup->resetExpiry(expireTimeoutMs(row));
}
void NotificationStore::updatePopupGroup(NotificationRow* row) {
if (row->m_popup && !row->m_closed) {
addPopupGroup(row);
@@ -1450,10 +1557,6 @@ bool NotificationStore::openDb() {
QSqlQuery query(db);
const QStringList statements = {
// WAL + NORMAL: commits don't fsync per insert (the fsync is what
// made every notification insert 5-8ms). Safe with WAL: an
// application crash still recovers the WAL; only an OS/power
// failure can lose the most recent commits.
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"CREATE TABLE IF NOT EXISTS notifications("
@@ -1479,8 +1582,6 @@ bool NotificationStore::openDb() {
}
}
// Databases created before progress was stored lack the column; ALTER adds
// it instead of dropping history (caelestia drops and recreates).
if (!db.record("notifications").contains("progress")) {
QSqlQuery alter(db);
if (!alter.exec(
@@ -1574,10 +1675,6 @@ void NotificationStore::loadRows() {
row->m_urgency = query.value(7).toInt();
row->m_resident = query.value(8).toInt() != 0;
row->m_hasActionIcons = query.value(9).toInt() != 0;
// Popup state is deliberately not restored: showing stored
// notifications as toasts again after a shell restart would
// replay an old burst, and with autoExpire off the flag never
// clears itself. The rows stay in the sidebar instead.
row->m_popup = false;
row->m_imageSource = query.value(12).toString();
row->m_digest = query.value(13).toString();
@@ -1649,7 +1746,6 @@ void NotificationStore::migrateLegacyJson() {
row->m_urgency = object.value("urgency").toInt(1);
row->m_resident = object.value("resident").toBool();
row->m_hasActionIcons = object.value("hasActionIcons").toBool();
// Same reasoning as loadRows(): never re-toast migrated rows.
row->m_popup = false;
const auto time = object.value("time");
@@ -1708,8 +1804,6 @@ void NotificationStore::prune() {
<< query.lastError();
}
// Total cap, preferring higher urgency so a chatty app cannot push out
// an important notification.
if (!query.exec(
"DELETE FROM notifications WHERE id NOT IN"
" (SELECT id FROM notifications ORDER BY urgency DESC, time DESC"
@@ -1720,7 +1814,6 @@ void NotificationStore::prune() {
<< query.lastError();
}
// Per-app cap: one app flooding must not crowd out the other groups.
QStringList appNames;
if (query.exec("SELECT DISTINCT app_name FROM notifications")) {
while (query.next()) {
@@ -1753,8 +1846,6 @@ void NotificationStore::trimRows() {
return;
}
// Only closed, unlocked rows are droppable (open rows may still be
// acted on by the sender). Weakest first: lowest urgency, then oldest.
const auto db = QSqlDatabase::database(m_uuid, false);
while (m_rows.size() > MAX_ROWS) {
@@ -1802,11 +1893,6 @@ void NotificationStore::startImageCache(NotificationRow* row) {
const QUrl url(row->m_imageSource);
// Resolve *what* to read on the main thread (engine provider lookups and
// QIcon's theme state belong here), but do no file scanning and no
// decoding here: providers are designed to serve requestImage from non-GUI
// threads, and SVG rasterization was blocking the shell 3-9ms per
// notification.
QString localPath;
QQuickImageProvider* provider = nullptr;
QString providerId;
@@ -1825,20 +1911,9 @@ void NotificationStore::startImageCache(NotificationRow* row) {
if (host != QLatin1String("qsimage") &&
host != QLatin1String("qspixmap")) {
// Provider urls such as image://icon/... are served by KF6's
// KIconLoader, which is not thread-safe (calling it from a
// worker crashed the shell). Only QIcon's theme state is read
// here, because the GUI thread owns it; the candidate scanning it
// feeds is pure filesystem work and runs in the worker below. A
// name the theme does not have means thousands of stat calls, and
// doing those here stalled inserts for milliseconds. If nothing
// matches, the row keeps rendering the url directly.
iconName = id;
theme = iconTheme();
} else {
// Transient inline notification images (image://qsimage/...).
// Provider objects are engine-owned and outlive this task;
// ids owned by a destroyed notification resolve to nothing.
auto* engine = qmlEngine(this);
if (engine == nullptr) {
return;
@@ -1864,10 +1939,6 @@ void NotificationStore::startImageCache(NotificationRow* row) {
return;
}
// Copy the pixels here instead of in the worker: the handle
// belongs to the sender's notification, and it can be gone by the
// time the task runs, which silently dropped the picture. This is
// a memory copy of an already decoded image, not a rasterization.
inlineImage =
usePixmap
? provider->requestPixmap(providerId, nullptr, QSize())
@@ -1938,8 +2009,6 @@ void NotificationStore::startImageCache(NotificationRow* row) {
normalized = normalized.convertToFormat(QImage::Format_RGBA8888);
// Cache the avatar content already cut to a circle, so the view
// does not have to mask it with a layer effect every frame.
const int side =
(std::min)(normalized.width(), normalized.height());
const QRect square(
@@ -2020,8 +2089,6 @@ void NotificationStore::storeImageBlob(
row->m_digest = digest;
row->m_image = QStringLiteral("image://notifimg/") + digest;
// The group reads its image through the source row and listens to
// imageChanged, so this emit is enough to keep the card in sync.
emit row->imageChanged();
}
}
@@ -2029,9 +2096,15 @@ void NotificationStore::storeImageBlob(
void NotificationStore::tick() {
const qint64 now = QDateTime::currentMSecsSinceEpoch();
for (auto* row : std::as_const(m_rows)) {
if (m_autoExpire && row->expired(now)) {
row->setPopup(false);
if (m_autoExpire) {
const auto groups = m_popupGroups;
for (auto* group : groups) {
if (group->expired(now)) {
expireGroup(group);
} else {
group->refreshExpiry(now);
}
}
}