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), + ), + ) } }