From af36d4934cbb334998e0ddc91e391183cf596939 Mon Sep 17 00:00:00 2001
From: Thorsten Ball <mrnugget@gmail.com>
Date: Tue, 13 Aug 2024 11:41:44 +0200
Subject: [PATCH] assistant panel: Animate assistant label if message is
 pending (#16152)

This adds a pulsating effect to the assistant header in case the message
is pending.

The pulsating effect is capped between 0.2 and 1.0 and I tried (with the
help of Claude) to give it a "breathing" effect, since I found the
normal bounce a bit too much.

Also opted for setting the `alpha` on the `LabelLike` things, vs.
overwriting the color, since I think that's cleaner instead of exposing
the color and mutating that.


https://github.com/user-attachments/assets/4a94a1c5-8dc7-4c40-b30f-d92d112db7b5


Release Notes:

- N/A
---
 crates/assistant/src/assistant_panel.rs       | 41 +++++++++++++++----
 crates/gpui/src/elements/animation.rs         | 18 ++++++++
 .../src/components/label/highlighted_label.rs |  5 +++
 crates/ui/src/components/label/label.rs       | 14 +++++++
 crates/ui/src/components/label/label_like.rs  | 17 +++++++-
 crates/ui/src/components/stories/label.rs     | 13 +++++-
 6 files changed, 98 insertions(+), 10 deletions(-)

diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs
index 03380a60a6215..ce40a9383908b 100644
--- a/crates/assistant/src/assistant_panel.rs
+++ b/crates/assistant/src/assistant_panel.rs
@@ -34,9 +34,9 @@ use editor::{
 use editor::{display_map::CreaseId, FoldPlaceholder};
 use fs::Fs;
 use gpui::{
-    canvas, div, percentage, point, Action, Animation, AnimationExt, AnyElement, AnyView,
-    AppContext, AsyncWindowContext, ClipboardItem, Context as _, DismissEvent, Empty, Entity,
-    EntityId, EventEmitter, FocusHandle, FocusableView, FontWeight, InteractiveElement,
+    canvas, div, percentage, point, pulsating_between, Action, Animation, AnimationExt, AnyElement,
+    AnyView, AppContext, AsyncWindowContext, ClipboardItem, Context as _, DismissEvent, Empty,
+    Entity, EntityId, EventEmitter, FocusHandle, FocusableView, FontWeight, InteractiveElement,
     IntoElement, Model, ParentElement, Pixels, ReadGlobal, Render, SharedString,
     StatefulInteractiveElement, Styled, Subscription, Task, Transformation, UpdateGlobal, View,
     ViewContext, VisualContext, WeakView, WindowContext,
@@ -2953,13 +2953,38 @@ impl ContextEditor {
                         let context = self.context.clone();
                         move |cx| {
                             let message_id = message.id;
+                            let show_spinner = message.role == Role::Assistant
+                                && message.status == MessageStatus::Pending;
+
+                            let label = match message.role {
+                                Role::User => {
+                                    Label::new("You").color(Color::Default).into_any_element()
+                                }
+                                Role::Assistant => {
+                                    let label = Label::new("Assistant").color(Color::Info);
+                                    if show_spinner {
+                                        label
+                                            .with_animation(
+                                                "pulsating-label",
+                                                Animation::new(Duration::from_secs(2))
+                                                    .repeat()
+                                                    .with_easing(pulsating_between(0.2, 1.0)),
+                                                |label, delta| label.alpha(delta),
+                                            )
+                                            .into_any_element()
+                                    } else {
+                                        label.into_any_element()
+                                    }
+                                }
+
+                                Role::System => Label::new("System")
+                                    .color(Color::Warning)
+                                    .into_any_element(),
+                            };
+
                             let sender = ButtonLike::new("role")
                                 .style(ButtonStyle::Filled)
-                                .child(match message.role {
-                                    Role::User => Label::new("You").color(Color::Default),
-                                    Role::Assistant => Label::new("Assistant").color(Color::Info),
-                                    Role::System => Label::new("System").color(Color::Warning),
-                                })
+                                .child(label)
                                 .tooltip(|cx| {
                                     Tooltip::with_meta(
                                         "Toggle message role",
diff --git a/crates/gpui/src/elements/animation.rs b/crates/gpui/src/elements/animation.rs
index 29506a622432e..def3d8c53ad39 100644
--- a/crates/gpui/src/elements/animation.rs
+++ b/crates/gpui/src/elements/animation.rs
@@ -170,6 +170,8 @@ impl<E: IntoElement + 'static> Element for AnimationElement<E> {
 }
 
 mod easing {
+    use std::f32::consts::PI;
+
     /// The linear easing function, or delta itself
     pub fn linear(delta: f32) -> f32 {
         delta
@@ -200,4 +202,20 @@ mod easing {
             }
         }
     }
+
+    /// A custom easing function for pulsating alpha that slows down as it approaches 0.1
+    pub fn pulsating_between(min: f32, max: f32) -> impl Fn(f32) -> f32 {
+        let range = max - min;
+
+        move |delta| {
+            // Use a combination of sine and cubic functions for a more natural breathing rhythm
+            let t = (delta * 2.0 * PI).sin();
+            let breath = (t * t * t + t) / 2.0;
+
+            // Map the breath to our desired alpha range
+            let normalized_alpha = (breath + 1.0) / 2.0;
+
+            min + (normalized_alpha * range)
+        }
+    }
 }
diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs
index a050215e581f5..7120dc7d480e2 100644
--- a/crates/ui/src/components/label/highlighted_label.rs
+++ b/crates/ui/src/components/label/highlighted_label.rs
@@ -53,6 +53,11 @@ impl LabelCommon for HighlightedLabel {
         self.base = self.base.italic(italic);
         self
     }
+
+    fn alpha(mut self, alpha: f32) -> Self {
+        self.base = self.base.alpha(alpha);
+        self
+    }
 }
 
 pub fn highlight_ranges(
diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs
index 5c61dcbc836ad..f29e4656e933c 100644
--- a/crates/ui/src/components/label/label.rs
+++ b/crates/ui/src/components/label/label.rs
@@ -156,6 +156,20 @@ impl LabelCommon for Label {
         self.base = self.base.italic(italic);
         self
     }
+
+    /// Sets the alpha property of the color of label.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use ui::prelude::*;
+    ///
+    /// let my_label = Label::new("Hello, World!").alpha(0.5);
+    /// ```
+    fn alpha(mut self, alpha: f32) -> Self {
+        self.base = self.base.alpha(alpha);
+        self
+    }
 }
 
 impl RenderOnce for Label {
diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs
index 4b830e656f9bb..b76fe845889d3 100644
--- a/crates/ui/src/components/label/label_like.rs
+++ b/crates/ui/src/components/label/label_like.rs
@@ -41,6 +41,9 @@ pub trait LabelCommon {
 
     /// Sets the italic property of the label.
     fn italic(self, italic: bool) -> Self;
+
+    /// Sets the alpha property of the label, overwriting the alpha value of the color.
+    fn alpha(self, alpha: f32) -> Self;
 }
 
 #[derive(IntoElement)]
@@ -53,6 +56,7 @@ pub struct LabelLike {
     strikethrough: bool,
     italic: bool,
     children: SmallVec<[AnyElement; 2]>,
+    alpha: Option<f32>,
 }
 
 impl LabelLike {
@@ -66,6 +70,7 @@ impl LabelLike {
             strikethrough: false,
             italic: false,
             children: SmallVec::new(),
+            alpha: None,
         }
     }
 }
@@ -111,6 +116,11 @@ impl LabelCommon for LabelLike {
         self.italic = italic;
         self
     }
+
+    fn alpha(mut self, alpha: f32) -> Self {
+        self.alpha = Some(alpha);
+        self
+    }
 }
 
 impl ParentElement for LabelLike {
@@ -123,6 +133,11 @@ impl RenderOnce for LabelLike {
     fn render(self, cx: &mut WindowContext) -> impl IntoElement {
         let settings = ThemeSettings::get_global(cx);
 
+        let mut color = self.color.color(cx);
+        if let Some(alpha) = self.alpha {
+            color.fade_out(1.0 - alpha);
+        }
+
         self.base
             .when(self.strikethrough, |this| {
                 this.relative().child(
@@ -144,7 +159,7 @@ impl RenderOnce for LabelLike {
                 this.line_height(relative(1.))
             })
             .when(self.italic, |this| this.italic())
-            .text_color(self.color.color(cx))
+            .text_color(color)
             .font_weight(self.weight.unwrap_or(settings.ui_font.weight))
             .children(self.children)
     }
diff --git a/crates/ui/src/components/stories/label.rs b/crates/ui/src/components/stories/label.rs
index 967512d8985c2..f4b30fb36e580 100644
--- a/crates/ui/src/components/stories/label.rs
+++ b/crates/ui/src/components/stories/label.rs
@@ -1,5 +1,7 @@
+use std::time::Duration;
+
 use crate::{prelude::*, HighlightedLabel, Label};
-use gpui::Render;
+use gpui::{pulsating_between, Animation, AnimationExt, Render};
 use story::Story;
 
 pub struct LabelStory;
@@ -23,5 +25,14 @@ impl Render for LabelStory {
             .child(
                 HighlightedLabel::new("Hello, world!", vec![0, 1, 2, 7, 8, 12]).color(Color::Error),
             )
+            .child(
+                Label::new("This text is pulsating").with_animation(
+                    "pulsating-label",
+                    Animation::new(Duration::from_secs(2))
+                        .repeat()
+                        .with_easing(pulsating_between(0.2, 1.0)),
+                    |label, delta| label.alpha(delta),
+                ),
+            )
     }
 }