Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2274,6 +2274,10 @@
"description": "Screen position for notification toasts",
"label": "Screen Position"
},
"route-to-source-output": {
"description": "Use the monitor containing a uniquely matched source-app window; otherwise use the selected monitors",
"label": "Route to Source App Monitor"
},
"scale": {
"description": "Scale notification toasts relative to the shell UI scale",
"label": "Size"
Expand Down
1 change: 1 addition & 0 deletions example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ scale = 1.0 # notification size multiplier applied on to
background_opacity = 0.97
offset_x = 20 # absolute horizontal margin from the screen edge
offset_y = 8 # absolute vertical margin from the screen edge
route_to_source_output = false # use a uniquely matched source-app monitor when available
# [notification.filter.rhythmbox]
# enabled = true
# match = "rhythmbox"
Expand Down
1 change: 1 addition & 0 deletions src/app/application_services.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,7 @@ void Application::initWaylandCallbacks() {
m_bar.refresh();
m_dock.refresh();
m_windowSwitcher.onToplevelChange();
m_notificationToast.onToplevelChange();
});
if constexpr (kLockKeysEnabled) {
if (lockKeysConsumersEnabled(m_configService.config())) {
Expand Down
4 changes: 3 additions & 1 deletion src/app/application_ui.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,9 @@ void Application::initPanelManagerAndPanels() {
}

void Application::initNotificationAndOsd() {
m_notificationToast.initialize(m_wayland, &m_configService, &m_notificationManager, &m_renderContext, &m_httpClient);
m_notificationToast.initialize(
m_wayland, &m_configService, &m_notificationManager, &m_renderContext, &m_compositorPlatform, &m_httpClient
);
m_configService.addReloadCallback([this]() { m_notificationToast.onConfigReload(); });
auto applyNotificationFilterConfig = [this]() {
m_notificationManager.setFilters(m_configService.config().notification.filters);
Expand Down
1 change: 1 addition & 0 deletions src/config/config_types.h
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,7 @@ struct NotificationConfig {
int offsetX = 20; // absolute horizontal margin from the screen edge
int offsetY = 8; // absolute vertical margin from the screen edge
std::vector<std::string> monitors;
bool routeToSourceOutput = false;
bool collapseOnDismiss = true;
int historyRetentionHours = 0;
int maxVisible = 0; // 0 = unlimited (space-based only)
Expand Down
1 change: 1 addition & 0 deletions src/config/schema/config_schema.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ namespace noctalia::config::schema {
field(&NotificationConfig::offsetX, "offset_x"),
field(&NotificationConfig::offsetY, "offset_y"),
field(&NotificationConfig::monitors, "monitors"),
field(&NotificationConfig::routeToSourceOutput, "route_to_source_output"),
field(&NotificationConfig::collapseOnDismiss, "collapse_on_dismiss"),
field(&NotificationConfig::historyRetentionHours, "history_retention_hours", Range<std::int64_t>{0, 8760}),
field(&NotificationConfig::maxVisible, "max_visible", Range<std::int64_t>{0, 20}),
Expand Down
86 changes: 82 additions & 4 deletions src/shell/notification/notification_toast.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "shell/notification/notification_toast.h"

#include "compositors/compositor_platform.h"
#include "config/config_service.h"
#include "config/config_types.h"
#include "core/deferred_call.h"
Expand All @@ -16,6 +17,8 @@
#include "render/render_target.h"
#include "render/scene/input_area.h"
#include "shell/surface/edge_inset.h"
#include "system/app_identity.h"
#include "system/desktop_entry.h"
#include "ui/builders.h"
#include "ui/palette.h"
#include "ui/style.h"
Expand Down Expand Up @@ -511,12 +514,13 @@ NotificationToast::~NotificationToast() {

void NotificationToast::initialize(
WaylandConnection& wayland, ConfigService* config, NotificationManager* notifications, RenderContext* renderContext,
HttpClient* httpClient
CompositorPlatform* platform, HttpClient* httpClient
) {
m_wayland = &wayland;
m_config = config;
m_notifications = notifications;
m_renderContext = renderContext;
m_platform = platform;
m_httpClient = httpClient;

m_callbackToken = m_notifications->addEventCallback([this](const Notification& n, NotificationEvent event) {
Expand All @@ -534,6 +538,7 @@ void NotificationToast::onConfigReload() {
return;
}
ensureSurfaces();
refreshNotificationOutputs();
std::vector<bool> wasPlaced(m_entries.size(), false);
for (std::size_t i = 0; i < m_entries.size(); ++i) {
wasPlaced[i] = hasPlacement(m_entries[i]);
Expand Down Expand Up @@ -573,12 +578,23 @@ void NotificationToast::onOutputChange() {
return;
}
ensureSurfaces();
refreshNotificationOutputs();
for (std::size_t i = 0; i < m_entries.size(); ++i) {
syncEntryVisibility(i);
}
requestLayout();
}

void NotificationToast::onToplevelChange() {
if (m_entries.empty() || m_config == nullptr || !m_config->config().notification.routeToSourceOutput) {
return;
}
refreshNotificationOutputs();
for (std::size_t i = 0; i < m_entries.size(); ++i) {
syncEntryVisibility(i);
}
}

void NotificationToast::hideDndSuppressed() {
std::erase_if(m_pendingAdds, [](const Notification& pending) {
return pending.dndPolicy == NotificationDndPolicy::Respect;
Expand Down Expand Up @@ -650,6 +666,8 @@ void NotificationToast::onNotificationEvent(const Notification& n, NotificationE
const float previousHeight = m_entries[i].height;
const int prevToastBodyLines = m_entries[i].toastBodyLines;
const bool previouslyPlaced = hasPlacement(m_entries[i]);
const wl_output* previousOutput = m_entries[i].targetOutput;
m_entries[i].targetOutput = resolveNotificationOutput(n);
m_entries[i].appName = notificationDisplayAppName(n);
m_entries[i].summary = n.summary;
m_entries[i].body = n.body;
Expand Down Expand Up @@ -817,7 +835,7 @@ void NotificationToast::onNotificationEvent(const Notification& n, NotificationE
m_notifications->pauseExpiry(n.id);
}

if (!hasPlacement(m_entries[i])) {
if (previousOutput != m_entries[i].targetOutput || !hasPlacement(m_entries[i])) {
syncEntryVisibility(i);
revealQueuedEntries();
} else if (std::abs(previousHeight - m_entries[i].height) > 0.5F) {
Expand Down Expand Up @@ -870,6 +888,7 @@ void NotificationToast::addPopup(const Notification& n) {

PopupEntry entry;
entry.notificationId = n.id;
entry.targetOutput = resolveNotificationOutput(n);
entry.appName = notificationDisplayAppName(n);
entry.summary = n.summary;
entry.body = n.body;
Expand Down Expand Up @@ -903,7 +922,10 @@ void NotificationToast::addPopup(const Notification& n) {
revealQueuedEntries();
enforceMaxVisible();

kLog.debug("notification toast: showing #{}", n.id);
const auto* output = m_wayland != nullptr ? m_wayland->findOutputByWl(m_entries[index].targetOutput) : nullptr;
kLog.debug(
"notification toast: showing #{} on {}", n.id, output != nullptr ? output->connectorName : "all eligible outputs"
);
}

void NotificationToast::requestClose(uint32_t notificationId, CloseReason reason) {
Expand Down Expand Up @@ -1001,7 +1023,9 @@ void NotificationToast::finishRemoval(uint32_t notificationId) {

void NotificationToast::addCardToInstance(Instance& inst, std::size_t entryIndex) {
auto& entry = m_entries[entryIndex];
if (!hasPlacement(entry) || !fitsOnSurface(entry, static_cast<float>(inst.surface->height()))) {
if (!hasPlacement(entry)
|| (entry.targetOutput != nullptr && entry.targetOutput != inst.output)
|| !fitsOnSurface(entry, static_cast<float>(inst.surface->height()))) {
return;
}

Expand Down Expand Up @@ -1182,6 +1206,7 @@ void NotificationToast::syncEntryVisibility(std::size_t entryIndex) {

auto& cs = inst->cards[entryIndex];
const bool shouldShow = hasPlacement(m_entries[entryIndex])
&& (m_entries[entryIndex].targetOutput == nullptr || m_entries[entryIndex].targetOutput == inst->output)
&& fitsOnSurface(m_entries[entryIndex], static_cast<float>(inst->surface->height()));
if (shouldShow) {
if (cs.cardNode == nullptr) {
Expand Down Expand Up @@ -1980,6 +2005,59 @@ uint32_t NotificationToast::surfaceHeightForOutput(wl_output* output) const {
return fallbackSurfaceHeight(notificationUiScale(m_config));
}

wl_output* NotificationToast::resolveNotificationOutput(const Notification& notification) const {
if (m_config == nullptr || !m_config->config().notification.routeToSourceOutput || m_platform == nullptr) {
return nullptr;
}

std::string appKey = notification.desktopEntry.value_or("");
if (StringUtils::isBlank(appKey)) {
appKey = notification.appName;
}
if (appKey.ends_with(".desktop")) {
appKey.resize(appKey.size() - std::string_view(".desktop").size());
}
if (const auto slash = appKey.find_last_of('/'); slash != std::string::npos) {
appKey.erase(0, slash + 1);
}
if (StringUtils::isBlank(appKey)) {
return nullptr;
}

std::string idLower = StringUtils::toLower(appKey);
std::string wmClassLower = idLower;
if (const auto entry = app_identity::findDesktopEntry(appKey, desktopEntries()); entry.has_value()) {
idLower = !entry->idLower.empty() ? entry->idLower : StringUtils::toLower(entry->id);
wmClassLower = !entry->startupWmClassLower.empty() ? entry->startupWmClassLower : idLower;
}

wl_output* match = nullptr;
for (const auto& inst : m_instances) {
if (inst == nullptr
|| inst->output == nullptr
|| m_platform->enrichedWindowsForApp(idLower, wmClassLower, inst->output).empty()) {
continue;
}
if (match != nullptr) {
return nullptr;
}
match = inst->output;
}
return match;
}

void NotificationToast::refreshNotificationOutputs() {
if (m_notifications == nullptr) {
return;
}
for (auto& entry : m_entries) {
const auto notification = std::ranges::find(m_notifications->all(), entry.notificationId, &Notification::id);
if (notification != m_notifications->all().end()) {
entry.targetOutput = resolveNotificationOutput(*notification);
}
}
}

// --- Surface lifecycle ---

void NotificationToast::ensureSurfaces() {
Expand Down
8 changes: 7 additions & 1 deletion src/shell/notification/notification_toast.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <vector>

class ConfigService;
class CompositorPlatform;
class HttpClient;
class Input;
class InputArea;
Expand Down Expand Up @@ -40,10 +41,11 @@ class NotificationToast {

void initialize(
WaylandConnection& wayland, ConfigService* config, NotificationManager* notifications,
RenderContext* renderContext, HttpClient* httpClient = nullptr
RenderContext* renderContext, CompositorPlatform* platform, HttpClient* httpClient = nullptr
);
void onConfigReload();
void onOutputChange();
void onToplevelChange();
void hideDndSuppressed();
void requestLayout();
void requestRedraw();
Expand All @@ -57,6 +59,7 @@ class NotificationToast {
// Per-notification visual state (shared across all instances)
struct PopupEntry {
uint32_t notificationId = 0;
wl_output* targetOutput = nullptr;
std::string appName;
std::string summary;
std::string body;
Expand Down Expand Up @@ -189,6 +192,8 @@ class NotificationToast {
[[nodiscard]] std::optional<float>
findPlacementY(float entryHeight, std::optional<uint32_t> ignoreNotificationId = std::nullopt) const;
[[nodiscard]] uint32_t surfaceHeightForOutput(wl_output* output) const;
[[nodiscard]] wl_output* resolveNotificationOutput(const Notification& notification) const;
void refreshNotificationOutputs();
// Configured render scale of a notification output, for pre-surface sizing.
[[nodiscard]] float notificationScale() const;
[[nodiscard]] std::string resolveNotificationIconPath(const PopupEntry& entry);
Expand All @@ -197,6 +202,7 @@ class NotificationToast {
ConfigService* m_config = nullptr;
NotificationManager* m_notifications = nullptr;
RenderContext* m_renderContext = nullptr;
CompositorPlatform* m_platform = nullptr;
HttpClient* m_httpClient = nullptr;

std::vector<PopupEntry> m_entries;
Expand Down
6 changes: 6 additions & 0 deletions src/shell/settings/settings_registry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2953,6 +2953,12 @@ namespace settings {
ListSetting{.items = cfg.notification.monitors, .suggestedOptions = env.availableOutputs},
"monitor output display screen"
));
entries.push_back(makeEntry(
SettingsSection::Notifications, "toasts", tr("settings.schema.notifications.route-to-source-output.label"),
tr("settings.schema.notifications.route-to-source-output.description"),
{"notification", "route_to_source_output"}, ToggleSetting{cfg.notification.routeToSourceOutput},
"source application monitor output route"
));
entries.push_back(makeEntry(
SettingsSection::Notifications, "history", tr("settings.schema.notifications.history-retention-hours.label"),
tr("settings.schema.notifications.history-retention-hours.description"),
Expand Down
1 change: 1 addition & 0 deletions tests/config_schema_roundtrip_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ location = "https://example.invalid/bad"
.offsetX = 12,
.offsetY = 6,
.monitors = {"DP-2"},
.routeToSourceOutput = true,
.collapseOnDismiss = false,
.historyRetentionHours = 48,
.filters = {NotificationFilterConfig{
Expand Down