Skip to main content

gpui_component/
bubble.rs

1use gpui::{
2    AnyElement, App, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, div,
3    prelude::FluentBuilder as _, relative, rems,
4};
5
6use crate::{
7    ActiveTheme as _, Colorize as _, StyledExt as _, button::Button, message::MessageAlignment,
8    v_flex,
9};
10
11/// Visual treatment for a chat bubble.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub enum BubbleVariant {
14    /// A filled primary surface.
15    #[default]
16    Filled,
17    /// A neutral secondary surface.
18    Secondary,
19    /// A lower-emphasis surface.
20    Muted,
21    /// A subtle primary-tinted surface.
22    Tinted,
23    /// A background surface with a visible border.
24    Outline,
25    /// No surface, padding, or border.
26    Ghost,
27    /// A destructive surface for failed or invalid content.
28    Destructive,
29}
30
31/// Edge on which reaction feedback is attached.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub enum BubbleReactionSide {
34    /// Attach reactions above the bubble.
35    Top,
36    /// Attach reactions below the bubble.
37    #[default]
38    Bottom,
39}
40
41/// A chat bubble layout that owns alignment, width, and reaction positioning.
42///
43/// The visible surface is rendered by [`BubbleContent`]. Direct children are
44/// added to that content slot as a convenience.
45#[derive(IntoElement)]
46pub struct Bubble {
47    style: StyleRefinement,
48    alignment: Option<MessageAlignment>,
49    variant: BubbleVariant,
50    content: BubbleContent,
51    reactions: Option<BubbleReactions>,
52}
53
54impl Bubble {
55    /// Create a filled bubble that can inherit alignment from its parent.
56    pub fn new() -> Self {
57        Self {
58            style: StyleRefinement::default(),
59            alignment: None,
60            variant: BubbleVariant::Filled,
61            content: BubbleContent::new(),
62            reactions: None,
63        }
64    }
65
66    /// Set the bubble alignment.
67    pub fn alignment(mut self, alignment: MessageAlignment) -> Self {
68        self.alignment = Some(alignment);
69        self
70    }
71
72    /// Set the visual treatment.
73    pub fn with_variant(mut self, variant: BubbleVariant) -> Self {
74        self.variant = variant;
75        self
76    }
77
78    pub(crate) fn is_ghost(&self) -> bool {
79        self.variant == BubbleVariant::Ghost
80    }
81
82    /// Replace the visible content surface.
83    ///
84    /// Children already added directly to the bubble move into the new
85    /// surface, in front of its own children, so `.child(...)` composes the
86    /// same way on either side of this call.
87    pub fn content(mut self, mut content: BubbleContent) -> Self {
88        let mut children = std::mem::take(&mut self.content.children);
89        children.append(&mut content.children);
90        content.children = children;
91        self.content = content;
92        self
93    }
94
95    /// Set an optional reaction region anchored to the bubble edge.
96    pub fn reactions(mut self, reactions: BubbleReactions) -> Self {
97        self.reactions = Some(reactions);
98        self
99    }
100}
101
102impl Default for Bubble {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl ParentElement for Bubble {
109    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
110        self.content.children.extend(elements);
111    }
112}
113
114impl Styled for Bubble {
115    fn style(&mut self) -> &mut StyleRefinement {
116        &mut self.style
117    }
118}
119
120impl RenderOnce for Bubble {
121    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
122        let variant = self.variant;
123        let mut content = self.content;
124        content.variant = variant;
125        content.alignment = self.alignment;
126
127        div()
128            .relative()
129            .flex()
130            .min_w_0()
131            .flex_col()
132            .flex_none()
133            .gap_1()
134            .max_w(relative(0.8))
135            .when(variant == BubbleVariant::Ghost, |this| {
136                this.w_full().max_w_full()
137            })
138            .when_some(self.alignment, |this, alignment| match alignment {
139                MessageAlignment::Start => this.self_start().mr_auto(),
140                MessageAlignment::End => this.self_end().ml_auto(),
141            })
142            .refine_style(&self.style)
143            .child(content)
144            .when_some(self.reactions, |this, reactions| this.child(reactions))
145    }
146}
147
148/// The visible surface inside a [`Bubble`].
149///
150/// This part owns padding, radius, border, typography, and semantic colors so
151/// callers can refine the surface without changing the bubble's row layout.
152#[derive(IntoElement)]
153pub struct BubbleContent {
154    style: StyleRefinement,
155    variant: BubbleVariant,
156    alignment: Option<MessageAlignment>,
157    children: Vec<AnyElement>,
158}
159
160impl BubbleContent {
161    /// Create an empty bubble content surface.
162    pub fn new() -> Self {
163        Self {
164            style: StyleRefinement::default(),
165            variant: BubbleVariant::default(),
166            alignment: None,
167            children: Vec::new(),
168        }
169    }
170}
171
172impl Default for BubbleContent {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178impl ParentElement for BubbleContent {
179    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
180        self.children.extend(elements);
181    }
182}
183
184impl Styled for BubbleContent {
185    fn style(&mut self) -> &mut StyleRefinement {
186        &mut self.style
187    }
188}
189
190impl RenderOnce for BubbleContent {
191    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
192        let tokens = cx.theme().semantic_tokens();
193
194        div()
195            .min_w_0()
196            .max_w_full()
197            .overflow_hidden()
198            .rounded(cx.theme().radius_2xl())
199            .border_1()
200            .border_color(cx.theme().transparent)
201            .px_3()
202            .py_2()
203            .text_sm()
204            .line_height(relative(1.625))
205            .when_some(self.alignment, |this, alignment| match alignment {
206                MessageAlignment::Start => this.self_start(),
207                MessageAlignment::End => this.self_end(),
208            })
209            .map(|this| match self.variant {
210                BubbleVariant::Filled => this
211                    .bg(tokens.colors.primary)
212                    .text_color(tokens.colors.primary_foreground),
213                // The theme's `secondary` role is tuned for buttons and sits a
214                // tier darker than shadcn's conversation secondary; the
215                // near-background `muted` tier matches shadcn's value in both
216                // light and dark themes.
217                BubbleVariant::Secondary => this
218                    .bg(tokens.colors.muted)
219                    .text_color(tokens.colors.secondary_foreground),
220                BubbleVariant::Muted => this
221                    .bg(tokens.colors.muted)
222                    .text_color(tokens.colors.foreground),
223                BubbleVariant::Tinted => this
224                    .bg(tokens.colors.primary.mix_oklab(
225                        tokens.colors.background,
226                        if cx.theme().is_dark() { 0.24 } else { 0.12 },
227                    ))
228                    .text_color(tokens.colors.foreground),
229                BubbleVariant::Outline => this
230                    .border_color(tokens.colors.border)
231                    .bg(tokens.colors.background)
232                    .text_color(tokens.colors.foreground),
233                BubbleVariant::Ghost => this
234                    .rounded(tokens.radius.none)
235                    .border_0()
236                    .bg(cx.theme().transparent)
237                    .text_color(tokens.colors.foreground)
238                    .p_0(),
239                BubbleVariant::Destructive => this
240                    .bg(tokens.colors.destructive.opacity(if cx.theme().is_dark() {
241                        0.2
242                    } else {
243                        0.1
244                    }))
245                    .text_color(tokens.colors.destructive),
246            })
247            .refine_style(&self.style)
248            .children(self.children)
249    }
250}
251
252/// A vertical stack of consecutive bubbles from one sender.
253#[derive(IntoElement)]
254pub struct BubbleGroup {
255    style: StyleRefinement,
256    children: Vec<AnyElement>,
257}
258
259impl BubbleGroup {
260    /// Create an empty bubble group.
261    pub fn new() -> Self {
262        Self {
263            style: StyleRefinement::default(),
264            children: Vec::new(),
265        }
266    }
267}
268
269impl Default for BubbleGroup {
270    fn default() -> Self {
271        Self::new()
272    }
273}
274
275impl ParentElement for BubbleGroup {
276    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
277        self.children.extend(elements);
278    }
279}
280
281impl Styled for BubbleGroup {
282    fn style(&mut self) -> &mut StyleRefinement {
283        &mut self.style
284    }
285}
286
287impl RenderOnce for BubbleGroup {
288    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
289        v_flex()
290            .min_w_0()
291            .gap_2()
292            .refine_style(&self.style)
293            .children(self.children)
294    }
295}
296
297enum BubbleReactionChild {
298    Action(Box<Button>),
299    Element(AnyElement),
300}
301
302/// A styleable reaction region positioned on a bubble edge.
303///
304/// Compose existing [`crate::button::Button`] values inside this region to
305/// preserve button semantics and keyboard behavior.
306#[derive(IntoElement)]
307pub struct BubbleReactions {
308    style: StyleRefinement,
309    side: BubbleReactionSide,
310    alignment: MessageAlignment,
311    children: Vec<BubbleReactionChild>,
312}
313
314impl BubbleReactions {
315    /// Create a trailing-aligned reaction region on the lower edge.
316    pub fn new() -> Self {
317        Self {
318            style: StyleRefinement::default(),
319            side: BubbleReactionSide::Bottom,
320            alignment: MessageAlignment::End,
321            children: Vec::new(),
322        }
323    }
324
325    /// Set the edge on which reactions are positioned.
326    pub fn side(mut self, side: BubbleReactionSide) -> Self {
327        self.side = side;
328        self
329    }
330
331    /// Set the reaction region alignment along the bubble edge.
332    pub fn alignment(mut self, alignment: MessageAlignment) -> Self {
333        self.alignment = alignment;
334        self
335    }
336
337    /// Add an interactive action that shares the reaction surface.
338    ///
339    /// Typed actions remove the reaction region's decorative content padding
340    /// and use the theme's full radius so the button reads as part of one
341    /// surface. The typed action owns that pill geometry; use `.child(...)`
342    /// for emoji, labels, and arbitrary elements when the child should retain
343    /// its own styling, including a custom button radius.
344    pub fn action(mut self, action: Button) -> Self {
345        self.children
346            .push(BubbleReactionChild::Action(Box::new(action)));
347        self
348    }
349}
350
351impl Default for BubbleReactions {
352    fn default() -> Self {
353        Self::new()
354    }
355}
356
357impl ParentElement for BubbleReactions {
358    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
359        self.children
360            .extend(elements.into_iter().map(BubbleReactionChild::Element));
361    }
362}
363
364impl Styled for BubbleReactions {
365    fn style(&mut self) -> &mut StyleRefinement {
366        &mut self.style
367    }
368}
369
370impl RenderOnce for BubbleReactions {
371    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
372        let tokens = cx.theme().semantic_tokens();
373        let has_action = self
374            .children
375            .iter()
376            .any(|child| matches!(child, BubbleReactionChild::Action(_)));
377        let action_radius = cx.theme().radius_full();
378        let children = self.children.into_iter().map(move |child| match child {
379            BubbleReactionChild::Action(action) => {
380                (*action).rounded(action_radius).into_any_element()
381            }
382            BubbleReactionChild::Element(element) => element,
383        });
384
385        div()
386            .absolute()
387            .flex()
388            .flex_none()
389            .items_center()
390            .justify_center()
391            .gap_1()
392            .rounded(cx.theme().radius_full())
393            .border_3()
394            .border_color(tokens.colors.background)
395            .bg(tokens.colors.muted)
396            .text_color(tokens.colors.foreground)
397            .when(!has_action, |this| this.px_1p5().py_0p5())
398            .text_sm()
399            // Approximates shadcn's `translate-y-3/4`: GPUI cannot offset by a
400            // fraction of the pill's own height, so this fixed value leaves
401            // about three quarters of the default pill outside the bubble.
402            .when(self.side == BubbleReactionSide::Top, |this| {
403                this.top(-rems(1.25))
404            })
405            .when(self.side == BubbleReactionSide::Bottom, |this| {
406                this.bottom(-rems(1.25))
407            })
408            .when(self.alignment == MessageAlignment::Start, |this| {
409                this.left_3()
410            })
411            .when(self.alignment == MessageAlignment::End, |this| {
412                this.right_3()
413            })
414            .refine_style(&self.style)
415            .children(children)
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn test_bubble_builder() {
425        let bubble = Bubble::new()
426            .alignment(MessageAlignment::End)
427            .with_variant(BubbleVariant::Outline)
428            .content(BubbleContent::new().child("Hello"))
429            .reactions(BubbleReactions::new().child("👍"));
430
431        assert_eq!(bubble.alignment, Some(MessageAlignment::End));
432        assert_eq!(bubble.variant, BubbleVariant::Outline);
433        assert_eq!(bubble.content.children.len(), 1);
434        assert!(bubble.reactions.is_some());
435
436        // Direct children survive a later `content(...)` call and stay in
437        // front of the new surface's own children.
438        let reordered = Bubble::new()
439            .child("Existing")
440            .content(BubbleContent::new().child("Configured"));
441        assert_eq!(reordered.content.children.len(), 2);
442
443        let group = BubbleGroup::new().child("First").child("Second");
444        assert_eq!(group.children.len(), 2);
445
446        let reactions = BubbleReactions::new()
447            .side(BubbleReactionSide::Top)
448            .alignment(MessageAlignment::Start)
449            .child("👍 2");
450
451        assert_eq!(reactions.side, BubbleReactionSide::Top);
452        assert_eq!(reactions.alignment, MessageAlignment::Start);
453        assert_eq!(reactions.children.len(), 1);
454
455        let reactions = BubbleReactions::new()
456            .action(Button::new("reaction-action"))
457            .child("👍");
458        assert_eq!(reactions.children.len(), 2);
459        assert!(matches!(
460            reactions.children.first(),
461            Some(BubbleReactionChild::Action(_))
462        ));
463        assert!(matches!(
464            reactions.children.get(1),
465            Some(BubbleReactionChild::Element(_))
466        ));
467    }
468}