callout.rs

  1use gpui::AnyElement;
  2
  3use crate::prelude::*;
  4
  5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
  6pub enum BorderPosition {
  7    Top,
  8    Bottom,
  9}
 10
 11/// A callout component for displaying important information that requires user attention.
 12///
 13/// # Usage Example
 14///
 15/// ```
 16/// use ui::prelude::*;
 17/// use ui::{Button, Callout, IconName, Label, Severity};
 18///
 19/// let callout = Callout::new()
 20///     .severity(Severity::Warning)
 21///     .icon(IconName::Warning)
 22///     .title("Be aware of your subscription!")
 23///     .description("Your subscription is about to expire. Renew now!")
 24///     .actions_slot(Button::new("renew", "Renew Now"));
 25/// ```
 26///
 27#[derive(IntoElement, RegisterComponent)]
 28pub struct Callout {
 29    severity: Severity,
 30    icon: Option<IconName>,
 31    title: Option<SharedString>,
 32    description: Option<SharedString>,
 33    description_slot: Option<AnyElement>,
 34    actions_slot: Option<AnyElement>,
 35    dismiss_action: Option<AnyElement>,
 36    line_height: Option<Pixels>,
 37    border_position: BorderPosition,
 38}
 39
 40impl Callout {
 41    /// Creates a new `Callout` component with default styling.
 42    pub fn new() -> Self {
 43        Self {
 44            severity: Severity::Info,
 45            icon: None,
 46            title: None,
 47            description: None,
 48            description_slot: None,
 49            actions_slot: None,
 50            dismiss_action: None,
 51            line_height: None,
 52            border_position: BorderPosition::Top,
 53        }
 54    }
 55
 56    /// Sets the severity of the callout.
 57    pub fn severity(mut self, severity: Severity) -> Self {
 58        self.severity = severity;
 59        self
 60    }
 61
 62    /// Sets the icon to display in the callout.
 63    pub fn icon(mut self, icon: IconName) -> Self {
 64        self.icon = Some(icon);
 65        self
 66    }
 67
 68    /// Sets the title of the callout.
 69    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
 70        self.title = Some(title.into());
 71        self
 72    }
 73
 74    /// Sets the description of the callout.
 75    /// The description can be single or multi-line text.
 76    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
 77        self.description = Some(description.into());
 78        self
 79    }
 80
 81    /// Allows for any element—like markdown elements—to fill the description slot of the callout.
 82    /// This method wins over `description` if both happen to be set.
 83    pub fn description_slot(mut self, description: impl IntoElement) -> Self {
 84        self.description_slot = Some(description.into_any_element());
 85        self
 86    }
 87
 88    /// Sets the primary call-to-action button.
 89    pub fn actions_slot(mut self, action: impl IntoElement) -> Self {
 90        self.actions_slot = Some(action.into_any_element());
 91        self
 92    }
 93
 94    /// Sets an optional dismiss button, which is usually an icon button with a close icon.
 95    /// This button is always rendered as the last one to the far right.
 96    pub fn dismiss_action(mut self, action: impl IntoElement) -> Self {
 97        self.dismiss_action = Some(action.into_any_element());
 98        self
 99    }
100
101    /// Sets a custom line height for the callout content.
102    pub fn line_height(mut self, line_height: Pixels) -> Self {
103        self.line_height = Some(line_height);
104        self
105    }
106
107    /// Sets the border position in the callout.
108    pub fn border_position(mut self, border_position: BorderPosition) -> Self {
109        self.border_position = border_position;
110        self
111    }
112}
113
114impl RenderOnce for Callout {
115    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
116        let line_height = self.line_height.unwrap_or(window.line_height());
117
118        let has_actions = self.actions_slot.is_some() || self.dismiss_action.is_some();
119
120        let (icon, icon_color, bg_color) = match self.severity {
121            Severity::Info => (
122                IconName::Info,
123                Color::Muted,
124                cx.theme().status().info_background.opacity(0.1),
125            ),
126            Severity::Success => (
127                IconName::Check,
128                Color::Success,
129                cx.theme().status().success.opacity(0.1),
130            ),
131            Severity::Warning => (
132                IconName::Warning,
133                Color::Warning,
134                cx.theme().status().warning_background.opacity(0.2),
135            ),
136            Severity::Error => (
137                IconName::XCircle,
138                Color::Error,
139                cx.theme().status().error.opacity(0.08),
140            ),
141        };
142
143        h_flex()
144            .min_w_0()
145            .w_full()
146            .p_2()
147            .gap_2()
148            .items_start()
149            .map(|this| match self.border_position {
150                BorderPosition::Top => this.border_t_1(),
151                BorderPosition::Bottom => this.border_b_1(),
152            })
153            .border_color(cx.theme().colors().border)
154            .bg(bg_color)
155            .overflow_x_hidden()
156            .when(self.icon.is_some(), |this| {
157                this.child(
158                    h_flex()
159                        .h(line_height)
160                        .justify_center()
161                        .child(Icon::new(icon).size(IconSize::Small).color(icon_color)),
162                )
163            })
164            .child(
165                v_flex()
166                    .min_w_0()
167                    .min_h_0()
168                    .w_full()
169                    .child(
170                        h_flex()
171                            .min_h(line_height)
172                            .w_full()
173                            .gap_1()
174                            .justify_between()
175                            .flex_wrap()
176                            .when_some(self.title, |this, title| {
177                                this.child(h_flex().child(Label::new(title).size(LabelSize::Small)))
178                            })
179                            .when(has_actions, |this| {
180                                this.child(
181                                    h_flex()
182                                        .gap_0p5()
183                                        .when_some(self.actions_slot, |this, action| {
184                                            this.child(action)
185                                        })
186                                        .when_some(self.dismiss_action, |this, action| {
187                                            this.child(action)
188                                        }),
189                                )
190                            }),
191                    )
192                    .map(|this| {
193                        let base_desc_container = div()
194                            .id("callout-description-slot")
195                            .w_full()
196                            .max_h_32()
197                            .flex_1()
198                            .overflow_y_scroll()
199                            .text_ui_sm(cx);
200
201                        if let Some(description_slot) = self.description_slot {
202                            this.child(base_desc_container.child(description_slot))
203                        } else if let Some(description) = self.description {
204                            this.child(
205                                base_desc_container
206                                    .text_color(cx.theme().colors().text_muted)
207                                    .child(description),
208                            )
209                        } else {
210                            this
211                        }
212                    }),
213            )
214    }
215}
216
217impl Component for Callout {
218    fn scope() -> ComponentScope {
219        ComponentScope::DataDisplay
220    }
221
222    fn description() -> Option<&'static str> {
223        Some(
224            "Used to display a callout for situations where the user needs to know some information, and likely make a decision. This might be a thread running out of tokens, or running out of prompts on a plan and needing to upgrade.",
225        )
226    }
227
228    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
229        let single_action = || Button::new("got-it", "Got it").label_size(LabelSize::Small);
230        let multiple_actions = || {
231            h_flex()
232                .gap_0p5()
233                .child(Button::new("update", "Backup & Update").label_size(LabelSize::Small))
234                .child(Button::new("dismiss", "Dismiss").label_size(LabelSize::Small))
235        };
236
237        let basic_examples = vec![
238            single_example(
239                "Simple with Title Only",
240                Callout::new()
241                    .icon(IconName::Info)
242                    .title("System maintenance scheduled for tonight")
243                    .actions_slot(single_action())
244                    .into_any_element(),
245            )
246            .width(px(580.)),
247            single_example(
248                "With Title and Description",
249                Callout::new()
250                    .icon(IconName::Warning)
251                    .title("Your settings contain deprecated values")
252                    .description(
253                        "We'll backup your current settings and update them to the new format.",
254                    )
255                    .actions_slot(single_action())
256                    .into_any_element(),
257            )
258            .width(px(580.)),
259            single_example(
260                "Error with Multiple Actions",
261                Callout::new()
262                    .icon(IconName::Close)
263                    .title("Thread reached the token limit")
264                    .description("Start a new thread from a summary to continue the conversation.")
265                    .actions_slot(multiple_actions())
266                    .into_any_element(),
267            )
268            .width(px(580.)),
269            single_example(
270                "Multi-line Description",
271                Callout::new()
272                    .icon(IconName::Sparkle)
273                    .title("Upgrade to Pro")
274                    .description("• Unlimited threads\n• Priority support\n• Advanced analytics")
275                    .actions_slot(multiple_actions())
276                    .into_any_element(),
277            )
278            .width(px(580.)),
279            single_example(
280                "Scrollable Long Description",
281                Callout::new()
282                    .severity(Severity::Error)
283                    .icon(IconName::XCircle)
284                    .title("Very Long API Error Description")
285                    .description_slot(
286                        v_flex().gap_1().children(
287                            [
288                                "You exceeded your current quota.",
289                                "For more information, visit the docs.",
290                                "Error details:",
291                                "• Quota exceeded for metric",
292                                "• Limit: 0",
293                                "• Model: gemini-3-pro",
294                                "Please retry in 26.33s.",
295                                "Additional details:",
296                                "- Request ID: abc123def456",
297                                "- Timestamp: 2024-01-15T10:30:00Z",
298                                "- Region: us-central1",
299                                "- Service: generativelanguage.googleapis.com",
300                                "- Error Code: RESOURCE_EXHAUSTED",
301                                "- Retry After: 26s",
302                                "This error occurs when you have exceeded your API quota.",
303                            ]
304                            .into_iter()
305                            .map(|t| Label::new(t).size(LabelSize::Small).color(Color::Muted)),
306                        ),
307                    )
308                    .actions_slot(single_action())
309                    .into_any_element(),
310            )
311            .width(px(580.)),
312        ];
313
314        let severity_examples = vec![
315            single_example(
316                "Info",
317                Callout::new()
318                    .icon(IconName::Info)
319                    .title("System maintenance scheduled for tonight")
320                    .actions_slot(single_action())
321                    .into_any_element(),
322            ),
323            single_example(
324                "Warning",
325                Callout::new()
326                    .severity(Severity::Warning)
327                    .icon(IconName::Triangle)
328                    .title("System maintenance scheduled for tonight")
329                    .actions_slot(single_action())
330                    .into_any_element(),
331            ),
332            single_example(
333                "Error",
334                Callout::new()
335                    .severity(Severity::Error)
336                    .icon(IconName::XCircle)
337                    .title("System maintenance scheduled for tonight")
338                    .actions_slot(single_action())
339                    .into_any_element(),
340            ),
341            single_example(
342                "Success",
343                Callout::new()
344                    .severity(Severity::Success)
345                    .icon(IconName::Check)
346                    .title("System maintenance scheduled for tonight")
347                    .actions_slot(single_action())
348                    .into_any_element(),
349            ),
350        ];
351
352        Some(
353            v_flex()
354                .gap_4()
355                .child(example_group(basic_examples).vertical())
356                .child(example_group_with_title("Severity", severity_examples).vertical())
357                .into_any_element(),
358        )
359    }
360}