diff --git a/src/animatedList/animatedList.vala b/src/animatedList/animatedList.vala index 1b1bf5bc..b230c17a 100644 --- a/src/animatedList/animatedList.vala +++ b/src/animatedList/animatedList.vala @@ -15,6 +15,37 @@ private struct WidgetHeights { private struct WidgetAlloc { float y; int height; + + public WidgetAlloc () { + y = 0; + height = 0; + } +} + +private class AnimationValueTarget : Object { + private double _progress = 0; + public double progress { + get { + return _progress; + } + set { + _progress = value; + cb (_progress); + } + } + + public delegate void callback (double value); + + private unowned callback ?cb; + + public AnimationValueTarget (float init_value, callback cb) { + this._progress = init_value; + this.cb = cb; + } + + public Adw.PropertyAnimationTarget get_animation_target () { + return new Adw.PropertyAnimationTarget (this, "progress"); + } } public class AnimatedList : Gtk.Widget, Gtk.Scrollable { diff --git a/src/controlCenter/widgets/notifications/notifications.vala b/src/controlCenter/widgets/notifications/notifications.vala index 7cc499bb..e10a7549 100644 --- a/src/controlCenter/widgets/notifications/notifications.vala +++ b/src/controlCenter/widgets/notifications/notifications.vala @@ -182,7 +182,7 @@ namespace SwayNotificationCenter.Widgets { noti_groups_name.lookup_extended (param.name_id, null, out group); } if (group == null || ConfigModel.instance.notification_grouping == false) { - group = new NotificationGroup (param.name_id, param.display_name); + group = new NotificationGroup (param.name_id, param.display_name, viewport); // Collapse other groups on expand group.on_expand_change.connect ((expanded) => { if (!expanded) { diff --git a/src/meson.build b/src/meson.build index 4a062815..3755b4ab 100644 --- a/src/meson.build +++ b/src/meson.build @@ -68,6 +68,7 @@ app_sources = [ 'dismissableWidget/dismissableWidget.vala', 'notification/notification.vala', 'notificationGroup/notificationGroup.vala', + 'notificationGroup/expandableGroup.vala', 'controlCenter/controlCenter.vala', widget_sources, 'blankWindow/blankWindow.vala', diff --git a/src/notificationGroup/expandableGroup.vala b/src/notificationGroup/expandableGroup.vala new file mode 100644 index 00000000..26bf6f10 --- /dev/null +++ b/src/notificationGroup/expandableGroup.vala @@ -0,0 +1,380 @@ +namespace SwayNotificationCenter { + private class ExpandableGroup : Gtk.Widget { + const int NUM_STACKED_NOTIFICATIONS = 3; + const int COLLAPSED_NOTIFICATION_OFFSET = 8; + + public bool is_expanded { get; private set; default = true; } + + private double animation_progress = 1.0; + private double animation_progress_inv { + get { + return (1 - animation_progress); + } + } + private AnimationValueTarget animation_target; + private Adw.TimedAnimation animation; + + public uint n_children { get; private set; } + public List widgets = new List (); + public List visible_widgets = new List (); + + public delegate void on_expand_change (bool state); + + private unowned Gtk.Viewport viewport; + + public ExpandableGroup (Gtk.Viewport viewport, + uint animation_duration) { + this.viewport = viewport; + + viewport.vadjustment.value_changed.connect (() => queue_allocate ()); + + base.set_can_focus (true); + + animation_target = new AnimationValueTarget (1.0f, animation_value_cb); + animation = new Adw.TimedAnimation (this, 1.0, 0.0, + animation_duration, + animation_target.get_animation_target ()); + animation.set_easing (Adw.Easing.EASE_IN_OUT_CUBIC); + animation.done.connect (animation_done_cb); + + set_expanded (false); + } + + public override void dispose () { + while (!widgets.is_empty ()) { + widgets.delete_link (widgets.nth (0)); + } + warn_if_fail (widgets.is_empty ()); + while (!visible_widgets.is_empty ()) { + visible_widgets.delete_link (visible_widgets.nth (0)); + } + warn_if_fail (visible_widgets.is_empty ()); + + base.dispose (); + } + + protected override Gtk.SizeRequestMode get_request_mode () { + foreach (unowned Gtk.Widget item in widgets) { + if (item.get_request_mode () != Gtk.SizeRequestMode.CONSTANT_SIZE) { + return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH; + } + } + return Gtk.SizeRequestMode.CONSTANT_SIZE; + } + + protected override void compute_expand_internal (out bool hexpand_p, + out bool vexpand_p) { + hexpand_p = false; + vexpand_p = false; + + foreach (unowned Gtk.Widget item in widgets) { + hexpand_p |= item.compute_expand (Gtk.Orientation.HORIZONTAL); + vexpand_p |= item.compute_expand (Gtk.Orientation.VERTICAL); + } + } + + public void set_expanded (bool value) { + if (is_expanded == value) { + return; + } + is_expanded = value; + + animate (is_expanded ? 1 : 0); + + this.queue_resize (); + } + + public void add (Gtk.Widget widget) { + widget.set_parent (this); + widgets.prepend (widget); + n_children++; + } + + public void remove (Gtk.Widget widget) { + widget.unparent (); + widgets.remove (widget); + if (this.get_visible () && widget.get_visible ()) { + queue_resize (); + } + n_children--; + } + + private delegate void compute_height_iter_cb (Gtk.Widget child, + WidgetAlloc widget_alloc, + int index, + WidgetAlloc first_alloc); + + private void compute_height (int width, + int height, + compute_height_iter_cb iter_callback) { + int num_vexpand_children = 0; + WidgetHeights measured_height = WidgetHeights (); + WidgetHeights[] heights = new WidgetHeights[n_children]; + int total_min = 0; + int total_nat = 0; + + int i = 0; + foreach (unowned Gtk.Widget child in widgets) { + if (!child.should_layout ()) { + continue; + } + + // Get the largest minimum height and use it for the fade distance + int nat_width; + // First, get the minimum width of our widget + child.measure (Gtk.Orientation.HORIZONTAL, -1, + null, out nat_width, null, null); + // Now use the natural width to retrieve the minimum and + // natural height to display. + int min_height, nat_height; + child.measure (Gtk.Orientation.VERTICAL, nat_width, + out min_height, out nat_height, null, null); + + int min, nat; + child.measure (Gtk.Orientation.VERTICAL, width, + out min, out nat, null, null); + heights[i] = WidgetHeights () { + min_height = min, + nat_height = nat, + }; + total_min += min; + total_nat += nat; + + if (child.compute_expand (Gtk.Orientation.VERTICAL)) { + num_vexpand_children++; + } + + i++; + } + + bool allocate_nat = false; + int extra_height = 0; + if (height >= measured_height.nat_height) { + allocate_nat = true; + extra_height = height - measured_height.nat_height; + } else { + warn_if_reached (); + } + + WidgetAlloc first_allocation = WidgetAlloc (); + int y = 0; + i = 0; + foreach (unowned Gtk.Widget child in widgets) { + WidgetHeights computed_height = heights[i]; + WidgetAlloc child_allocation = WidgetAlloc () { + y = 0, + height = computed_height.min_height, + }; + if (allocate_nat) { + child_allocation.height = computed_height.nat_height; + } + + if (child.compute_expand (Gtk.Orientation.VERTICAL)) { + child_allocation.height += extra_height / num_vexpand_children; + } + + child_allocation.y = y; + + if (i == 0) { + first_allocation = child_allocation; + } + iter_callback (child, child_allocation, i, first_allocation); + + y += child_allocation.height; + i++; + } + } + + private inline bool is_outside_bounds (float y, int height, float max_height) { + return y + height < 0 || y > max_height; + } + + protected override void size_allocate (int width, int height, int baseline) { + base.size_allocate (width, height, baseline); + + // Recalculate which children are visible, so clear the old list + while (!visible_widgets.is_empty ()) { + visible_widgets.delete_link (visible_widgets.nth (0)); + } + warn_if_fail (visible_widgets.is_empty ()); + + + if (n_children == 0) { + return; + } + + Graphene.Rect bounds; + this.compute_bounds (viewport, out bounds); + float max_height = viewport.get_height (); + + compute_height (width, height, (child, child_allocation, index, target_alloc) => { + // Allocate the size and position of each item + if (!child.should_layout ()) { + return; + } + + // Expand or shrink stacked notifications to the expected height + // (the most recent notification when collapsed) + int child_height = (int) Adw.lerp (target_alloc.height, child_allocation.height, + animation_progress); + + // + // Cull non-visible widgets + // + + Graphene.Point viewport_relative_coords; + this.compute_point ( + viewport, + Graphene.Point ().init (0, child_allocation.y), + out viewport_relative_coords); + + bool skip_child = false; + bool outside_bounds + = is_outside_bounds (viewport_relative_coords.y, child_height, max_height); + if (animation_progress > 0 && animation_progress < 1) { + // Skip out of bounds notifications + if (index >= NUM_STACKED_NOTIFICATIONS && outside_bounds) { + skip_child = true; + } + } else if (!is_expanded) { + // Skip out of bounds notifications, except for the first 3 + if (index >= NUM_STACKED_NOTIFICATIONS) { + skip_child = true; + } + } else if (outside_bounds) { + // Skip out of bounds notifications + skip_child = true; + } + + if (skip_child) { + return; + } + + // + // Allocate the widget + // + + visible_widgets.prepend (child); + + float scale = 1.0f; + float opacity = 1.0f; + float x = 0; + float y = (float) Adw.lerp (0, child_allocation.y, animation_progress); + + // Add the collapsed offset to only stacked notifications. + // Excludes notifications index > NUM_STACKED_NOTIFICATIONS + if (index < NUM_STACKED_NOTIFICATIONS) { + scale = (float) double.min ( + animation_progress + Math.pow (0.95, index), 1); + // Moves the scaled notification to the center of X and bottom y + x = (width - width * scale) * 0.5f; + y += child_height * (1 - scale); + // Apply a vertical offset to the notification + y += (int) Adw.lerp (COLLAPSED_NOTIFICATION_OFFSET, 0, + animation_progress) * index; + } else { + opacity = (float) (1.5f * animation_progress); + } + + Gsk.Transform transform = new Gsk.Transform () + .translate (Graphene.Point ().init (x, y)) + .scale (scale, scale); + child.allocate (width, child_height, baseline, transform); + child.set_opacity (opacity); + }); + } + + protected override void measure (Gtk.Orientation orientation, int for_size, + out int minimum, out int natural, + out int minimum_baseline, out int natural_baseline) { + minimum = 0; + natural = 0; + minimum_baseline = -1; + natural_baseline = -1; + + if (orientation == Gtk.Orientation.HORIZONTAL || n_children == 0) { + return; + } + + foreach (unowned Gtk.Widget widget in widgets) { + if (!widget.should_layout ()) { + continue; + } + + int min_height = 0; + int nat_height = 0; + widget.measure (orientation, for_size, + out min_height, + out nat_height, + null, null); + minimum += min_height; + natural += nat_height; + } + + int target_nat_height; + int target_min_height; + get_height_for_latest_notifications (for_size, out target_min_height, + out target_nat_height); + // TODO: Always use natural as minimum? + // Fixes large (tall) Notification body Pictures + minimum = (int) Functions.lerp (minimum, + target_nat_height, + animation_progress_inv); + natural = (int) Functions.lerp (natural, + target_nat_height, + animation_progress_inv); + } + + protected override void snapshot (Gtk.Snapshot snapshot) { + foreach (unowned Gtk.Widget child in visible_widgets) { + if (!child.should_layout ()) { + continue; + } + snapshot_child (child, snapshot); + } + } + + /** Gets the collapsed height (first notification + stacked) */ + private void get_height_for_latest_notifications (int for_size, + out int minimum, + out int natural) { + minimum = 0; + natural = 0; + + if (n_children == 0) { + return; + } + + unowned List first = widgets.first (); + if (first != null) { + unowned Gtk.Widget last_widget = first.data; + + last_widget.measure (Gtk.Orientation.VERTICAL, for_size, + out minimum, out natural, + null, null); + } + + int offset = (int) (n_children - 1).clamp (0, NUM_STACKED_NOTIFICATIONS - 1) + * COLLAPSED_NOTIFICATION_OFFSET; + + natural += offset; + } + + void animation_value_cb (double progress) { + this.animation_progress = progress; + + this.queue_resize (); + } + + void animation_done_cb () { + this.queue_allocate (); + } + + void animate (double to) { + animation.set_value_from (animation_progress); + animation.set_value_to (to); + animation.reset (); + animation.play (); + } + } +} diff --git a/src/notificationGroup/notificationGroup.vala b/src/notificationGroup/notificationGroup.vala index 521adb03..d45f1176 100644 --- a/src/notificationGroup/notificationGroup.vala +++ b/src/notificationGroup/notificationGroup.vala @@ -22,7 +22,8 @@ namespace SwayNotificationCenter { public signal void on_expand_change (bool state); - public NotificationGroup (string name_id, string display_name) { + public NotificationGroup (string name_id, string display_name, + Gtk.Viewport viewport) { this.name_id = name_id; add_css_class ("notification-group"); @@ -104,16 +105,10 @@ namespace SwayNotificationCenter { set_activatable (false); - group = new ExpandableGroup (Constants.ANIMATION_DURATION, (state) => { - revealer.set_reveal_child (state); + group = new ExpandableGroup (viewport, Constants.ANIMATION_DURATION); + box.append (group); - // Change CSS Class - if (parent != null) { - set_classes (); - } - }); set_classes (); - box.append (group); /* * Handling of group presses @@ -204,7 +199,6 @@ namespace SwayNotificationCenter { if (param.desktop_app_info != null && (icon = param.desktop_app_info.get_icon ()) != null) { app_icon.set_from_gicon (icon); - app_icon.show (); } else { app_icon.set_from_icon_name ("application-x-executable-symbolic"); } @@ -217,6 +211,12 @@ namespace SwayNotificationCenter { public void set_expanded (bool state) { group.set_expanded (state); + revealer.set_reveal_child (state); + // Change CSS Class + if (parent != null) { + set_classes (); + } + group.set_sensitive (only_single_notification () || group.is_expanded); dismissible.set_can_dismiss (!state); } @@ -261,14 +261,14 @@ namespace SwayNotificationCenter { } public unowned Notification ?get_latest_notification () { - return (Notification ?) group.widgets.last ().data; + return (Notification ?) group.widgets.first ().data; } public int64 get_time () { if (group.widgets.is_empty ()) { return -1; } - return ((Notification) group.widgets.last ().data).param.time; + return ((Notification) group.widgets.first ().data).param.time; } public bool get_is_urgent () { @@ -311,327 +311,4 @@ namespace SwayNotificationCenter { return dest_point.y; } } - - private class ExpandableGroup : Gtk.Widget { - const int NUM_STACKED_NOTIFICATIONS = 3; - const int COLLAPSED_NOTIFICATION_OFFSET = 8; - - public bool is_expanded { get; private set; default = true; } - - private double animation_progress = 1.0; - private double animation_progress_inv { - get { - return (1 - animation_progress); - } - } - private Adw.TimedAnimation animation; - private Adw.CallbackAnimationTarget animation_target; - - private unowned on_expand_change change_cb; - - public List widgets = new List (); - - public delegate void on_expand_change (bool state); - - public ExpandableGroup (uint animation_duration, on_expand_change change_cb) { - base.set_can_focus (true); - - this.change_cb = change_cb; - - animation_target = new Adw.CallbackAnimationTarget (animation_value_cb); - animation = new Adw.TimedAnimation (this, 1.0, 0.0, - animation_duration, - animation_target); - animation.set_easing (Adw.Easing.EASE_IN_OUT_CUBIC); - animation.done.connect (animation_done_cb); - - this.show (); - - set_expanded (false); - } - - public void set_expanded (bool value) { - if (is_expanded == value) { - return; - } - is_expanded = value; - - animate (is_expanded ? 1 : 0); - - this.queue_resize (); - - change_cb (is_expanded); - } - - public void add (Gtk.Widget widget) { - widget.set_parent (this); - widgets.append (widget); - } - - public void remove (Gtk.Widget widget) { - widget.unparent (); - widgets.remove (widget); - if (this.get_visible () && widget.get_visible ()) { - queue_resize (); - } - } - - private Gtk.Allocation get_alloc (Gtk.Widget w) { - Gtk.Allocation alloc = Gtk.Allocation (); - Graphene.Rect bounds; - w.compute_bounds (this, out bounds); - - alloc.width = w.get_width (); - alloc.height = w.get_height (); - alloc.x = (int) bounds.origin.x; - alloc.y = (int) bounds.origin.y; - return alloc; - } - - public override Gtk.SizeRequestMode get_request_mode () { - return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH; - } - - protected override void measure (Gtk.Orientation orientation, int for_size, - out int minimum, out int natural, - out int minimum_baseline, out int natural_baseline) { - minimum = 0; - natural = 0; - minimum_baseline = -1; - natural_baseline = -1; - - if (orientation == Gtk.Orientation.HORIZONTAL) { - return; - } - - foreach (unowned Gtk.Widget widget in widgets) { - if (widget != null && widget.get_visible ()) { - int widget_minimum_height = 0; - int widget_natural_height = 0; - widget.measure (orientation, for_size, - out widget_minimum_height, - out widget_natural_height, - null, null); - - minimum += widget_minimum_height; - natural += widget_natural_height; - } - } - - int target_natural_height; - int target_minimum_height; - get_height_for_latest_notifications (for_size, out target_minimum_height, - out target_natural_height); - // TODO: Always use natural as minimum? - // Fixes large (tall) Notification body Pictures - minimum = (int) Functions.lerp (minimum, - target_natural_height, - animation_progress_inv); - natural = (int) Functions.lerp (natural, - target_natural_height, - animation_progress_inv); - } - - protected override void size_allocate (int alloc_width, int alloc_height, int baseline) { - base.size_allocate (alloc_width, alloc_height, baseline); - - int length = (int) widgets.length (); - if (length == 0) { - return; - } - - Gtk.Allocation allocation = get_alloc (this); - allocation.width = alloc_width; - allocation.height = alloc_height; - - Gtk.Allocation prev_allocation = Gtk.Allocation (); - prev_allocation.y = allocation.y; - - // The height of the most recent notification - unowned Gtk.Widget last = widgets.last ().data; - int target_height = 0; - - last.measure (Gtk.Orientation.VERTICAL, allocation.width, null, out target_height, null, - null); - - for (int i = length - 1; i >= 0; i--) { - unowned Gtk.Widget widget = widgets.nth_data (i); - if (widget != null && widget.get_visible ()) { - int height; - widget.measure (Gtk.Orientation.VERTICAL, allocation.width, - null, out height, - null, null); - - Gtk.Allocation alloc = Gtk.Allocation (); - alloc.x = allocation.x; - alloc.y = (int) (prev_allocation.y + - animation_progress * prev_allocation.height); - alloc.width = allocation.width; - alloc.height = height; - // Expand smaller stacked notifications to the expected height - // But only when the animation has finished - if (target_height > height && !is_expanded && animation_progress == 0) { - alloc.height = target_height; - } - - // Add the collapsed offset to only stacked notifications. - // Excludes notifications index > NUM_STACKED_NOTIFICATIONS - if (i < length - 1 && length - 1 - i < NUM_STACKED_NOTIFICATIONS) { - alloc.y += (int) (animation_progress_inv * COLLAPSED_NOTIFICATION_OFFSET); - } - - prev_allocation = alloc; - Gsk.Transform transform = new Gsk.Transform (); - transform = transform.translate (Graphene.Point ().init (alloc.x, alloc.y)); - widget.allocate (alloc.width, alloc.height, baseline, transform); - - if (get_realized ()) { - widget.show (); - } - } - if (get_realized ()) { - widget.set_child_visible (true); - } - } - } - - // Draw the widget - protected override void snapshot (Gtk.Snapshot snapshot) { - int length = (int) widgets.length (); - if (length == 0) { - return; - } - - Graphene.Rect bounds = Graphene.Rect (); - if (!compute_bounds (this, out bounds)) { - return; - } - Gtk.Allocation alloc = get_alloc (this); - int width = alloc.width; - - unowned Gtk.Widget latest = widgets.nth_data (length - 1); - Gtk.Allocation latest_alloc = get_alloc (latest); - - for (int i = 0; i < length; i++) { - // Skip drawing excess notifications - if (!is_expanded && - animation_progress == 0 && - i < length - NUM_STACKED_NOTIFICATIONS) { - continue; - } - - unowned Gtk.Widget widget = widgets.nth_data (i); - Gtk.Allocation widget_alloc = get_alloc (widget); - - int height_diff = latest_alloc.height - widget_alloc.height; - - snapshot.save (); - - // Move down even more if the height is larger than the latest - // in the stack (helps with only rendering the bottom portion) - double translate_y = height_diff * animation_progress_inv; - snapshot.translate (Graphene.Point ().init (0, (float) translate_y)); - - // Scale down lower notifications in the stack - if (i + 1 != length) { - double scale = double.min ( - animation_progress + Math.pow (0.95, length - 1 - i), 1); - // Moves the scaled notification to the center of X and bottom y - snapshot.translate (Graphene.Point ().init ( - (float) ((widget_alloc.width - width * scale) * 0.5), - (float) (widget_alloc.height * (1 - scale)))); - snapshot.scale ((float) scale, (float) scale); - } - - int lerped_y = (int) Functions.lerp (-height_diff, 0, animation_progress); - lerped_y += (int) widget_alloc.y - alloc.y; - int lerped_height = (int) Functions.lerp (latest_alloc.height, - widget_alloc.height, - animation_progress); - // Clip to the size of the latest notification - // (fixes issue where a larger bottom notification would - // be visible above) - Graphene.Rect clip_bounds = Graphene.Rect ().init (0f, - (float) lerped_y, - (float) width, - (float) lerped_height); - snapshot.push_clip (clip_bounds); - - // TODO: Fades from the bottom at 0.5 -> top at 0.0 opacity - // Draw patterns on the notification - // cr.push_group (); - // widget.draw (cr); - // if (i + 1 != length) { - //// Draw Fade Gradient - // cr.save (); - // cr.translate (0, lerped_y); - // cr.scale (1, lerped_height * 0.5); - // cr.set_source (fade_gradient); - // cr.rectangle (0, 0, width, lerped_height * 0.5); - // cr.set_operator (Cairo.Operator.DEST_OUT); - // cr.fill (); - // cr.restore (); - // } - // cr.pop_group_to_source (); - // cr.paint (); - - // Cross-fades in the non visible stacked notifications when expanded - if (i < length - NUM_STACKED_NOTIFICATIONS) { - snapshot.push_opacity (1.5 * animation_progress); - } - snapshot_child (widget, snapshot); - - if (i < length - NUM_STACKED_NOTIFICATIONS) { - snapshot.pop (); // Cross-fade - } - snapshot.pop (); // Clip - - snapshot.restore (); - } - } - - /** Gets the collapsed height (first notification + stacked) */ - private void get_height_for_latest_notifications (int for_size, - out int minimum, - out int natural) { - minimum = 0; - natural = 0; - - int length = (int) widgets.length (); - if (length == 0) { - return; - } - - unowned GLib.List last = widgets.last (); - if (last != null) { - unowned Gtk.Widget last_widget = widgets.last ().data; - - last_widget.measure (Gtk.Orientation.VERTICAL, for_size, - out minimum, out natural, - null, null); - } - - int offset = (length - 1).clamp (0, NUM_STACKED_NOTIFICATIONS - 1) - * COLLAPSED_NOTIFICATION_OFFSET; - - natural += offset; - } - - void animation_value_cb (double progress) { - this.animation_progress = progress; - - this.queue_resize (); - } - - void animation_done_cb () { - this.queue_allocate (); - } - - void animate (double to) { - animation.set_value_from (animation_progress); - animation.set_value_to (to); - animation.reset (); - animation.play (); - } - } } diff --git a/src/notificationWindow/notificationWindow.vala b/src/notificationWindow/notificationWindow.vala index c4109980..9905450b 100644 --- a/src/notificationWindow/notificationWindow.vala +++ b/src/notificationWindow/notificationWindow.vala @@ -172,7 +172,7 @@ namespace SwayNotificationCenter { } private void set_input_region () { - unowned Gdk.Surface ?surface = window.get_surface (); + unowned Gdk.Surface ?surface = get_surface (); if (surface == null) { return; }