ui: Refactor the Callout component (#32684)

What motivated me to refactor this component was the fact that I wanted
a new variant to allow having _two CTAs_ instead of just one. This
variant should work with either a single or multiline description. But,
given we were using a `Callout::single_line` and `Callout::multi_line`
API, I'd then need to have both `Callout::single_line_one_button` and
`Callout::single_line_two_buttons` type of variants, which just points
to a combinatorial problem.

With this refactor, the Callout now follows the same structure of the
Banner component, where it's all `Callout::new` and every method is
passed as if they were props in a React component, allowing for a more
flexible design where you can customize button styles. Also made it
slightly more robust for wrapping and removed the top border as that
should be defined by the place it is being used in.

Release Notes:

- N/A
This commit is contained in:
Danilo Leal 2025-06-13 10:03:32 -03:00 committed by GitHub
parent aa1cb9c1e1
commit 29f3e62850
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 212 additions and 129 deletions

View file

@ -39,7 +39,7 @@ use proto::Plan;
use settings::Settings;
use std::time::Duration;
use theme::ThemeSettings;
use ui::{Disclosure, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
use ui::{Callout, Disclosure, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
use util::{ResultExt as _, maybe};
use workspace::{CollaboratorId, Workspace};
use zed_llm_client::CompletionIntent;
@ -1175,6 +1175,7 @@ impl MessageEditor {
.map_or(false, |model| {
model.provider.id().0 == ZED_CLOUD_PROVIDER_ID
});
if !is_using_zed_provider {
return None;
}
@ -1229,14 +1230,6 @@ impl MessageEditor {
token_usage_ratio: TokenUsageRatio,
cx: &mut Context<Self>,
) -> Option<Div> {
let title = if token_usage_ratio == TokenUsageRatio::Exceeded {
"Thread reached the token limit"
} else {
"Thread reaching the token limit soon"
};
let message = "Start a new thread from a summary to continue the conversation.";
let icon = if token_usage_ratio == TokenUsageRatio::Exceeded {
Icon::new(IconName::X)
.color(Color::Error)
@ -1247,19 +1240,36 @@ impl MessageEditor {
.size(IconSize::XSmall)
};
let title = if token_usage_ratio == TokenUsageRatio::Exceeded {
"Thread reached the token limit"
} else {
"Thread reaching the token limit soon"
};
Some(
div()
.child(ui::Callout::multi_line(
title,
message,
icon,
"Start New Thread",
Box::new(cx.listener(|this, _, window, cx| {
let from_thread_id = Some(this.thread.read(cx).id().clone());
window.dispatch_action(Box::new(NewThread { from_thread_id }), cx);
})),
))
.line_height(line_height),
.border_t_1()
.border_color(cx.theme().colors().border)
.child(
Callout::new()
.line_height(line_height)
.icon(icon)
.title(title)
.description(
"Start a new thread from a summary to continue the conversation.",
)
.primary_action(
Button::new("start-new-thread", "Start New Thread")
.label_size(LabelSize::Small)
.on_click(cx.listener(|this, _, window, cx| {
let from_thread_id = Some(this.thread.read(cx).id().clone());
window.dispatch_action(
Box::new(NewThread { from_thread_id }),
cx,
);
})),
),
),
)
}

View file

@ -2,7 +2,7 @@ use client::zed_urls;
use component::{empty_example, example_group_with_title, single_example};
use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
use language_model::RequestUsage;
use ui::{Callout, Color, Icon, IconName, IconSize, prelude::*};
use ui::{Callout, prelude::*};
use zed_llm_client::{Plan, UsageLimit};
#[derive(IntoElement, RegisterComponent)]
@ -91,16 +91,23 @@ impl RenderOnce for UsageCallout {
.size(IconSize::XSmall)
};
Callout::multi_line(
title,
message,
icon,
button_text,
Box::new(move |_, _, cx| {
cx.open_url(&url);
}),
)
.into_any_element()
div()
.border_t_1()
.border_color(cx.theme().colors().border)
.child(
Callout::new()
.icon(icon)
.title(title)
.description(message)
.primary_action(
Button::new("upgrade", button_text)
.label_size(LabelSize::Small)
.on_click(move |_, _, cx| {
cx.open_url(&url);
}),
),
)
.into_any_element()
}
}
@ -189,10 +196,8 @@ impl Component for UsageCallout {
);
Some(
div()
v_flex()
.p_4()
.flex()
.flex_col()
.gap_4()
.child(free_examples)
.child(trial_examples)

View file

@ -1,51 +1,77 @@
use gpui::ClickEvent;
use gpui::AnyElement;
use crate::prelude::*;
/// A callout component for displaying important information that requires user attention.
///
/// # Usage Example
///
/// ```
/// use ui::{Callout};
///
/// Callout::new()
/// .icon(Icon::new(IconName::Warning).color(Color::Warning))
/// .title(Label::new("Be aware of your subscription!"))
/// .description(Label::new("Your subscription is about to expire. Renew now!"))
/// .primary_action(Button::new("renew", "Renew Now"))
/// .secondary_action(Button::new("remind", "Remind Me Later"))
/// ```
///
#[derive(IntoElement, RegisterComponent)]
pub struct Callout {
title: SharedString,
message: Option<SharedString>,
icon: Icon,
cta_label: SharedString,
cta_action: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
icon: Option<Icon>,
title: Option<SharedString>,
description: Option<SharedString>,
primary_action: Option<AnyElement>,
secondary_action: Option<AnyElement>,
line_height: Option<Pixels>,
}
impl Callout {
pub fn single_line(
title: impl Into<SharedString>,
icon: Icon,
cta_label: impl Into<SharedString>,
cta_action: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
) -> Self {
/// Creates a new `Callout` component with default styling.
pub fn new() -> Self {
Self {
title: title.into(),
message: None,
icon,
cta_label: cta_label.into(),
cta_action,
icon: None,
title: None,
description: None,
primary_action: None,
secondary_action: None,
line_height: None,
}
}
pub fn multi_line(
title: impl Into<SharedString>,
message: impl Into<SharedString>,
icon: Icon,
cta_label: impl Into<SharedString>,
cta_action: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
) -> Self {
Self {
title: title.into(),
message: Some(message.into()),
icon,
cta_label: cta_label.into(),
cta_action,
line_height: None,
}
/// Sets the icon to display in the callout.
pub fn icon(mut self, icon: Icon) -> Self {
self.icon = Some(icon);
self
}
/// Sets the title of the callout.
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
/// Sets the description of the callout.
/// The description can be single or multi-line text.
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
/// Sets the primary call-to-action button.
pub fn primary_action(mut self, action: impl IntoElement) -> Self {
self.primary_action = Some(action.into_any_element());
self
}
/// Sets an optional secondary call-to-action button.
pub fn secondary_action(mut self, action: impl IntoElement) -> Self {
self.secondary_action = Some(action.into_any_element());
self
}
/// Sets a custom line height for the callout content.
pub fn line_height(mut self, line_height: Pixels) -> Self {
self.line_height = Some(line_height);
self
@ -57,57 +83,54 @@ impl RenderOnce for Callout {
let line_height = self.line_height.unwrap_or(window.line_height());
h_flex()
.w_full()
.p_2()
.gap_2()
.w_full()
.items_center()
.justify_between()
.items_start()
.bg(cx.theme().colors().panel_background)
.border_t_1()
.border_color(cx.theme().colors().border)
.overflow_x_hidden()
.when_some(self.icon, |this, icon| {
this.child(h_flex().h(line_height).justify_center().child(icon))
})
.child(
h_flex()
.flex_shrink()
.overflow_hidden()
.gap_2()
.items_start()
v_flex()
.w_full()
.child(
h_flex()
.h(line_height)
.items_center()
.justify_center()
.child(self.icon),
.w_full()
.gap_1()
.flex_wrap()
.justify_between()
.when_some(self.title, |this, title| {
this.child(h_flex().child(Label::new(title).size(LabelSize::Small)))
})
.when(
self.primary_action.is_some() || self.secondary_action.is_some(),
|this| {
this.child(
h_flex()
.gap_1()
.when_some(self.secondary_action, |this, action| {
this.child(action)
})
.when_some(self.primary_action, |this, action| {
this.child(action)
}),
)
},
),
)
.child(
v_flex()
.flex_shrink()
.overflow_hidden()
.child(
h_flex()
.h(line_height)
.items_center()
.child(Label::new(self.title).size(LabelSize::Small)),
)
.when_some(self.message, |this, message| {
this.child(
div()
.w_full()
.flex_1()
.child(message)
.text_ui_sm(cx)
.text_color(cx.theme().colors().text_muted),
)
}),
),
)
.child(
div().flex_none().child(
Button::new("cta", self.cta_label)
.on_click(self.cta_action)
.style(ButtonStyle::Filled)
.label_size(LabelSize::Small),
),
.when_some(self.description, |this, description| {
this.child(
div()
.w_full()
.flex_1()
.child(description)
.text_ui_sm(cx)
.text_color(cx.theme().colors().text_muted),
)
}),
)
}
}
@ -126,30 +149,75 @@ impl Component for Callout {
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let callout_examples = vec![
single_example(
"Single Line",
Callout::single_line(
"Your settings contain deprecated values, please update them.",
Icon::new(IconName::Warning)
.color(Color::Warning)
.size(IconSize::Small),
"Backup & Update",
Box::new(|_, _, _| {}),
)
.into_any_element(),
"Simple with Title Only",
Callout::new()
.icon(
Icon::new(IconName::Info)
.color(Color::Accent)
.size(IconSize::Small),
)
.title("System maintenance scheduled for tonight")
.primary_action(Button::new("got-it", "Got it").label_size(LabelSize::Small))
.into_any_element(),
)
.width(px(580.)),
single_example(
"Multi Line",
Callout::multi_line(
"Thread reached the token limit",
"Start a new thread from a summary to continue the conversation.",
Icon::new(IconName::X)
.color(Color::Error)
.size(IconSize::Small),
"Start New Thread",
Box::new(|_, _, _| {}),
)
.into_any_element(),
"With Title and Description",
Callout::new()
.icon(
Icon::new(IconName::Warning)
.color(Color::Warning)
.size(IconSize::Small),
)
.title("Your settings contain deprecated values")
.description(
"We'll backup your current settings and update them to the new format.",
)
.primary_action(
Button::new("update", "Backup & Update").label_size(LabelSize::Small),
)
.secondary_action(
Button::new("dismiss", "Dismiss").label_size(LabelSize::Small),
)
.into_any_element(),
)
.width(px(580.)),
single_example(
"Error with Multiple Actions",
Callout::new()
.icon(
Icon::new(IconName::X)
.color(Color::Error)
.size(IconSize::Small),
)
.title("Thread reached the token limit")
.description("Start a new thread from a summary to continue the conversation.")
.primary_action(
Button::new("new-thread", "Start New Thread").label_size(LabelSize::Small),
)
.secondary_action(
Button::new("view-summary", "View Summary").label_size(LabelSize::Small),
)
.into_any_element(),
)
.width(px(580.)),
single_example(
"Multi-line Description",
Callout::new()
.icon(
Icon::new(IconName::Sparkle)
.color(Color::Accent)
.size(IconSize::Small),
)
.title("Upgrade to Pro")
.description("• Unlimited threads\n• Priority support\n• Advanced analytics")
.primary_action(
Button::new("upgrade", "Upgrade Now").label_size(LabelSize::Small),
)
.secondary_action(
Button::new("learn-more", "Learn More").label_size(LabelSize::Small),
)
.into_any_element(),
)
.width(px(580.)),
];