diff --git a/compositor/NotificationStack.vala b/compositor/NotificationStack.vala
new file mode 100644
index 00000000..3ba274cd
--- /dev/null
+++ b/compositor/NotificationStack.vala
@@ -0,0 +1,210 @@
+/*
+ * Copyright 2020-2025 elementary, Inc (https://elementary.io)
+ * 2014 Tom Beckmann
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+public class GreeterCompositor.NotificationStack : Object {
+ private const string TRANSITION_ENTRY_NAME = "entry";
+ private const int CLOSE_ANIMATION_DURATION = 195;
+
+ // we need to keep a small offset to the top, because we clip the container to
+ // its allocations and the close button would be off for the first notification
+ private const int TOP_OFFSET = 2;
+ private const int ADDITIONAL_MARGIN = 12;
+ private const int MARGIN = 12;
+
+ private const int WIDTH = 300;
+
+ private int stack_y;
+ private int stack_width;
+
+ public Meta.Display display { get; construct; }
+
+ private Gee.ArrayList notifications;
+
+ public NotificationStack (Meta.Display display) {
+ Object (display: display);
+ }
+
+ construct {
+ notifications = new Gee.ArrayList ();
+
+ unowned var monitor_manager = display.get_context ().get_backend ().get_monitor_manager ();
+ monitor_manager.monitors_changed_internal.connect (update_stack_allocation);
+ display.workareas_changed.connect (update_stack_allocation);
+ update_stack_allocation ();
+ }
+
+ public void show_notification (Meta.WindowActor notification)
+ requires (notification != null && !notification.is_destroyed () && !notifications.contains (notification)) {
+
+ notification.set_pivot_point (0.5f, 0.5f);
+
+ unowned var window = notification.get_meta_window ();
+ if (window == null) {
+ warning ("NotificationStack: Unable to show notification, window is null");
+ return;
+ }
+
+ var window_rect = window.get_frame_rect ();
+ window.stick ();
+
+ if (Meta.Prefs.get_gnome_animations ()) {
+ // Don't flicker at the beginning of the animation
+ notification.opacity = 0;
+ notification.rotation_angle_x = 90;
+
+ var opacity_transition = new Clutter.PropertyTransition ("opacity");
+ opacity_transition.set_from_value (0);
+ opacity_transition.set_to_value (255);
+
+ var flip_transition = new Clutter.KeyframeTransition ("rotation-angle-x");
+ flip_transition.set_from_value (90.0);
+ flip_transition.set_to_value (0.0);
+ flip_transition.set_key_frames ({ 0.6 });
+ flip_transition.set_values ({ -10.0 });
+
+ var entry = new Clutter.TransitionGroup () {
+ duration = 400
+ };
+ entry.add_transition (opacity_transition);
+ entry.add_transition (flip_transition);
+
+ notification.transitions_completed.connect (() => notification.remove_all_transitions ());
+ notification.add_transition (TRANSITION_ENTRY_NAME, entry);
+ }
+
+ /**
+ * We will make space for the incoming notification
+ * by shifting all current notifications by height
+ * and then add it to the notifications list.
+ */
+ update_positions (window_rect.height);
+
+ var primary = display.get_primary_monitor ();
+ var area = display.get_workspace_manager ().get_active_workspace ().get_work_area_for_monitor (primary);
+ var scale = display.get_monitor_scale (primary);
+
+ int notification_x_pos = area.x + area.width - window_rect.width;
+ if (Clutter.get_default_text_direction () == Clutter.TextDirection.RTL) {
+ notification_x_pos = 0;
+ }
+
+ move_window (notification, notification_x_pos, stack_y + TOP_OFFSET + Utils.scale_to_int (ADDITIONAL_MARGIN, scale));
+ notifications.insert (0, notification);
+ }
+
+ private void update_stack_allocation () {
+ var primary = display.get_primary_monitor ();
+ var area = display.get_workspace_manager ().get_active_workspace ().get_work_area_for_monitor (primary);
+
+ var scale = display.get_monitor_scale (primary);
+ stack_width = Utils.scale_to_int (WIDTH + MARGIN, scale);
+
+ stack_y = area.y;
+
+ update_positions ();
+ }
+
+ private void update_positions (float add_y = 0.0f) {
+ var scale = display.get_monitor_scale (display.get_primary_monitor ());
+
+ var y = stack_y + TOP_OFFSET + add_y + Utils.scale_to_int (ADDITIONAL_MARGIN, scale);
+ var i = notifications.size;
+ var delay_step = i > 0 ? 150 / i : 0;
+ var iterator = 0;
+ // Need to iterate like this since we might be removing entries
+ while (notifications.size > iterator) {
+ unowned var actor = notifications.get (iterator);
+ iterator++;
+ if (actor == null || actor.is_destroyed ()) {
+ warning ("NotificationStack: Notification actor was null or destroyed");
+ continue;
+ }
+
+ if (Meta.Prefs.get_gnome_animations ()) {
+ actor.save_easing_state ();
+ actor.set_easing_mode (Clutter.AnimationMode.EASE_OUT_BACK);
+ actor.set_easing_duration (200);
+ actor.set_easing_delay ((i--) * delay_step);
+ }
+
+ move_window (actor, -1, (int)y);
+
+ if (Meta.Prefs.get_gnome_animations ()) {
+ actor.restore_easing_state ();
+ }
+
+ unowned var window = actor.get_meta_window ();
+ if (window == null) {
+ // Mutter doesn't let us know when a window is closed if a workspace
+ // transition is in progress. I'm not really sure why, but what this
+ // means is that we have to remove the notification from the stack
+ // manually.
+ // See https://github.com/GNOME/mutter/blob/3.36.9/src/compositor/meta-window-actor.c#L882
+ notifications.remove (actor);
+ warning ("NotificationStack: Notification window was null (probably removed during workspace transition?)");
+ continue;
+ }
+
+ y += window.get_frame_rect ().height;
+ }
+ }
+
+ public void destroy_notification (Meta.WindowActor notification) {
+ notification.save_easing_state ();
+ notification.set_easing_duration (Utils.get_animation_duration (CLOSE_ANIMATION_DURATION));
+ notification.set_easing_mode (Clutter.AnimationMode.EASE_IN_QUAD);
+ notification.opacity = 0;
+
+ notification.x += stack_width;
+ notification.restore_easing_state ();
+
+ notifications.remove (notification);
+ update_positions ();
+ }
+
+ /**
+ * This function takes care of properly updating both the actor
+ * position and the actual window position.
+ *
+ * To enable animations for a window we first need to move it's frame
+ * in the compositor and then calculate & apply the coordinates for the window
+ * actor.
+ */
+ private static void move_window (Meta.WindowActor actor, int x, int y) requires (actor != null && !actor.is_destroyed ()) {
+ unowned var window = actor.get_meta_window ();
+ if (window == null) {
+ warning ("NotificationStack: Unable to move the window, window is null");
+ return;
+ }
+
+ var rect = window.get_frame_rect ();
+
+ window.move_frame (false, x != -1 ? x : rect.x, y != -1 ? y : rect.y);
+
+ /**
+ * move_frame does not guarantee that the frame rectangle
+ * will be updated instantly, get the buffer rectangle.
+ */
+ rect = window.get_buffer_rect ();
+ actor.set_position (rect.x - ((actor.width - rect.width) / 2), rect.y - ((actor.height - rect.height) / 2));
+ }
+
+ public static bool is_notification (Meta.Window window) {
+ return window.window_type == NOTIFICATION || window.get_data (NOTIFICATION_DATA_KEY);
+ }
+}
diff --git a/compositor/ShellClients/NotificationsClient.vala b/compositor/ShellClients/NotificationsClient.vala
index b0819cc4..4d5e1495 100644
--- a/compositor/ShellClients/NotificationsClient.vala
+++ b/compositor/ShellClients/NotificationsClient.vala
@@ -1,5 +1,5 @@
/*
- * Copyright 2024 elementary, Inc. (https://elementary.io)
+ * Copyright 2024-2025 elementary, Inc. (https://elementary.io)
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Authored by: Leonhard Kargl
@@ -26,6 +26,7 @@ public class GreeterCompositor.NotificationsClient : Object {
client.window_created.connect ((window) => {
window.set_data (NOTIFICATION_DATA_KEY, true);
window.make_above ();
+ window.stick ();
#if HAS_MUTTER46
client.wayland_client.make_dock (window);
#endif
diff --git a/compositor/ShellClients/ShellClientsManager.vala b/compositor/ShellClients/ShellClientsManager.vala
index d6695b26..417e5969 100644
--- a/compositor/ShellClients/ShellClientsManager.vala
+++ b/compositor/ShellClients/ShellClientsManager.vala
@@ -1,5 +1,5 @@
/*
- * Copyright 2024 elementary, Inc. (https://elementary.io)
+ * Copyright 2024-2025 elementary, Inc. (https://elementary.io)
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Authored by: Leonhard Kargl
@@ -178,7 +178,20 @@ public class GreeterCompositor.ShellClientsManager : Object {
}
public bool is_itself_positioned (Meta.Window window) {
- return (window in positioned_windows) || (window in panel_windows) || window.get_data (NOTIFICATION_DATA_KEY);
+ return (window in positioned_windows) || (window in panel_windows) || NotificationStack.is_notification (window);
+ }
+
+ public bool is_positioned_window (Meta.Window window) {
+ bool positioned = is_itself_positioned (window);
+ window.foreach_ancestor ((ancestor) => {
+ if (is_itself_positioned (ancestor)) {
+ positioned = true;
+ }
+
+ return !positioned;
+ });
+
+ return positioned;
}
//X11 only
diff --git a/compositor/Utils.vala b/compositor/Utils.vala
index ba337b68..6756ed33 100644
--- a/compositor/Utils.vala
+++ b/compositor/Utils.vala
@@ -1,6 +1,6 @@
/*
* Copyright 2012 Tom Beckmann, Rico Tzschichholz
- * Copyright 2018 elementary LLC. (https://elementary.io)
+ * Copyright 2018-2025 elementary, Inc. (https://elementary.io)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -53,6 +53,58 @@ namespace GreeterCompositor {
return (int) (Math.round ((float)value * scale_factor));
}
+ /**
+ * Utility that returns the given duration or 0 if animations are disabled.
+ */
+ public static uint get_animation_duration (uint duration) {
+ return Meta.Prefs.get_gnome_animations () ? duration : 0;
+ }
+
+ public static void clutter_actor_reparent (Clutter.Actor actor, Clutter.Actor new_parent) {
+ if (actor == new_parent) {
+ return;
+ }
+
+ actor.ref ();
+ actor.get_parent ().remove_child (actor);
+ new_parent.add_child (actor);
+ actor.unref ();
+ }
+
+ public delegate void WindowActorReadyCallback (Meta.WindowActor window_actor);
+
+ public static void wait_for_window_actor (Meta.Window window, owned WindowActorReadyCallback callback) {
+ unowned var window_actor = (Meta.WindowActor) window.get_compositor_private ();
+ if (window_actor != null) {
+ callback (window_actor);
+ return;
+ }
+
+ Idle.add (() => {
+ window_actor = (Meta.WindowActor) window.get_compositor_private ();
+
+ if (window_actor != null) {
+ callback (window_actor);
+ }
+
+ return Source.REMOVE;
+ });
+ }
+
+ public static void wait_for_window_actor_visible (Meta.Window window, owned WindowActorReadyCallback callback) {
+ wait_for_window_actor (window, (window_actor) => {
+ if (window_actor.visible) {
+ callback (window_actor);
+ } else {
+ ulong show_handler = 0;
+ show_handler = window_actor.show.connect (() => {
+ window_actor.disconnect (show_handler);
+ callback (window_actor);
+ });
+ }
+ });
+ }
+
private static Gtk.StyleContext selection_style_context = null;
public static Gdk.RGBA get_theme_accent_color () {
if (selection_style_context == null) {
diff --git a/compositor/WindowManager.vala b/compositor/WindowManager.vala
index fb54a140..3e5e57e7 100644
--- a/compositor/WindowManager.vala
+++ b/compositor/WindowManager.vala
@@ -25,16 +25,18 @@ namespace GreeterCompositor {
public Clutter.Stage stage { get; protected set; }
public Clutter.Actor window_group { get; protected set; }
public Clutter.Actor top_window_group { get; protected set; }
-
- /**
- * The background group is a container for the background actors forming the wallpaper
- */
public Meta.BackgroundGroup background_group { get; protected set; }
-
public PointerLocator pointer_locator { get; private set; }
-
public GreeterCompositor.SystemBackground system_background { get; private set; }
+ /**
+ * The group that contains all WindowActors that make shell elements, that is all windows reported as
+ * ShellClientsManager.is_positioned_window.
+ * It will (eventually) never be hidden by other components and is always on top of everything. Therefore elements are
+ * responsible themselves for hiding depending on the state we are currently in (e.g. normal desktop, open multitasking view, fullscreen, etc.).
+ */
+ private Clutter.Actor shell_group;
+ private NotificationStack notification_stack;
private Clutter.Actor fade_in_screen;
#if !HAS_MUTTER48
@@ -89,6 +91,8 @@ namespace GreeterCompositor {
DBusWingpanelManager.init (this);
KeyboardManager.init (display);
+ notification_stack = new NotificationStack (display);
+
#if HAS_MUTTER48
stage = display.get_compositor ().get_stage () as Clutter.Stage;
#else
@@ -143,6 +147,10 @@ namespace GreeterCompositor {
window_group.add_child (background_group);
window_group.set_child_below_sibling (background_group, null);
+ // Add the remaining components that should be on top
+ shell_group = new Clutter.Actor ();
+ ui_group.add_child (shell_group);
+
pointer_locator = new PointerLocator (this);
ui_group.add_child (pointer_locator);
@@ -192,6 +200,10 @@ namespace GreeterCompositor {
toggle_screen_reader (); // sync screen reader with gsettings key
application_settings.changed["screen-reader-enabled"].connect (toggle_screen_reader);
+ display.window_created.connect ((window) =>
+ Utils.wait_for_window_actor_visible (window, check_shell_window)
+ );
+
stage.show ();
Idle.add (() => {
@@ -288,6 +300,17 @@ namespace GreeterCompositor {
}
}
+ private void check_shell_window (Meta.WindowActor actor) {
+ unowned var window = actor.get_meta_window ();
+ if (ShellClientsManager.get_instance ().is_positioned_window (window)) {
+ Utils.clutter_actor_reparent (actor, shell_group);
+ }
+
+ if (NotificationStack.is_notification (window)) {
+ notification_stack.show_notification (actor);
+ }
+ }
+
public override void show_window_menu_for_rect (Meta.Window window, Meta.WindowMenuType menu, Mtk.Rectangle rect) {
show_window_menu (window, menu, rect.x, rect.y);
}
diff --git a/compositor/meson.build b/compositor/meson.build
index 786a571a..3a85aee6 100644
--- a/compositor/meson.build
+++ b/compositor/meson.build
@@ -98,6 +98,7 @@ compositor_files = files(
'KeyboardManager.vala',
'main.vala',
'MediaFeedback.vala',
+ 'NotificationStack.vala',
'PointerLocator.vala',
'Utils.vala',
'WindowManager.vala',