diff --git a/assets/translations/en.json b/assets/translations/en.json index ad2daa813f..b1f1d27d54 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -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" diff --git a/example.toml b/example.toml index 0a45ae8415..316654d62b 100644 --- a/example.toml +++ b/example.toml @@ -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" diff --git a/src/app/application_services.cpp b/src/app/application_services.cpp index ef940cf5e8..0069492b4e 100644 --- a/src/app/application_services.cpp +++ b/src/app/application_services.cpp @@ -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())) { diff --git a/src/app/application_ui.cpp b/src/app/application_ui.cpp index da576b7a1c..410f2f6e8d 100644 --- a/src/app/application_ui.cpp +++ b/src/app/application_ui.cpp @@ -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); diff --git a/src/config/config_types.h b/src/config/config_types.h index 9c36fcf14a..e895ce14ea 100644 --- a/src/config/config_types.h +++ b/src/config/config_types.h @@ -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 monitors; + bool routeToSourceOutput = false; bool collapseOnDismiss = true; int historyRetentionHours = 0; int maxVisible = 0; // 0 = unlimited (space-based only) diff --git a/src/config/schema/config_schema.cpp b/src/config/schema/config_schema.cpp index 1f219b3891..1f6d383849 100644 --- a/src/config/schema/config_schema.cpp +++ b/src/config/schema/config_schema.cpp @@ -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{0, 8760}), field(&NotificationConfig::maxVisible, "max_visible", Range{0, 20}), diff --git a/src/shell/notification/notification_toast.cpp b/src/shell/notification/notification_toast.cpp index b5a3cec29a..35fbce6c94 100644 --- a/src/shell/notification/notification_toast.cpp +++ b/src/shell/notification/notification_toast.cpp @@ -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" @@ -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" @@ -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) { @@ -534,6 +538,7 @@ void NotificationToast::onConfigReload() { return; } ensureSurfaces(); + refreshNotificationOutputs(); std::vector wasPlaced(m_entries.size(), false); for (std::size_t i = 0; i < m_entries.size(); ++i) { wasPlaced[i] = hasPlacement(m_entries[i]); @@ -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; @@ -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; @@ -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) { @@ -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; @@ -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) { @@ -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(inst.surface->height()))) { + if (!hasPlacement(entry) + || (entry.targetOutput != nullptr && entry.targetOutput != inst.output) + || !fitsOnSurface(entry, static_cast(inst.surface->height()))) { return; } @@ -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(inst->surface->height())); if (shouldShow) { if (cs.cardNode == nullptr) { @@ -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() { diff --git a/src/shell/notification/notification_toast.h b/src/shell/notification/notification_toast.h index 7c3f9a39ed..866aedf378 100644 --- a/src/shell/notification/notification_toast.h +++ b/src/shell/notification/notification_toast.h @@ -12,6 +12,7 @@ #include class ConfigService; +class CompositorPlatform; class HttpClient; class Input; class InputArea; @@ -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(); @@ -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; @@ -189,6 +192,8 @@ class NotificationToast { [[nodiscard]] std::optional findPlacementY(float entryHeight, std::optional 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); @@ -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 m_entries; diff --git a/src/shell/settings/settings_registry.cpp b/src/shell/settings/settings_registry.cpp index bd9d3b5c05..775488c5e5 100644 --- a/src/shell/settings/settings_registry.cpp +++ b/src/shell/settings/settings_registry.cpp @@ -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"), diff --git a/tests/config_schema_roundtrip_test.cpp b/tests/config_schema_roundtrip_test.cpp index e2ec6041c4..8f8177b1d2 100644 --- a/tests/config_schema_roundtrip_test.cpp +++ b/tests/config_schema_roundtrip_test.cpp @@ -377,6 +377,7 @@ location = "https://example.invalid/bad" .offsetX = 12, .offsetY = 6, .monitors = {"DP-2"}, + .routeToSourceOutput = true, .collapseOnDismiss = false, .historyRetentionHours = 48, .filters = {NotificationFilterConfig{