Skip to content
Merged
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
210 changes: 210 additions & 0 deletions compositor/NotificationStack.vala
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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<unowned Meta.WindowActor> notifications;

public NotificationStack (Meta.Display display) {
Object (display: display);
}

construct {
notifications = new Gee.ArrayList<unowned Meta.WindowActor> ();

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);
}
}
3 changes: 2 additions & 1 deletion compositor/ShellClients/NotificationsClient.vala
Original file line number Diff line number Diff line change
@@ -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 <leo.kargl@proton.me>
Expand All @@ -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
Expand Down
17 changes: 15 additions & 2 deletions compositor/ShellClients/ShellClientsManager.vala
Original file line number Diff line number Diff line change
@@ -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 <leo.kargl@proton.me>
Expand Down Expand Up @@ -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
Expand Down
54 changes: 53 additions & 1 deletion compositor/Utils.vala
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Expand Down
35 changes: 29 additions & 6 deletions compositor/WindowManager.vala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 (() => {
Expand Down Expand Up @@ -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);
}
Expand Down
Loading