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',