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            // A ghost bubble has no surface to clip against; clipping would
198            // only cut the shadows and overhanging controls of rich content.
199            .when(self.variant != BubbleVariant::Ghost, |this| {
200                this.overflow_hidden()
201            })
202            .rounded(cx.theme().radius_2xl())
203            .border_1()
204            .border_color(cx.theme().transparent)
205            .px_3()
206            .py_2()
207            .text_sm()
208            .line_height(relative(1.625))
209            .when_some(self.alignment, |this, alignment| match alignment {
210                MessageAlignment::Start => this.self_start(),
211                MessageAlignment::End => this.self_end(),
212            })
213            .map(|this| match self.variant {
214                BubbleVariant::Filled => this
215                    .bg(tokens.colors.primary)
216                    .text_color(tokens.colors.primary_foreground),
217                // The theme's `secondary` role is tuned for buttons and sits a
218                // tier darker than shadcn's conversation secondary; the
219                // near-background `muted` tier matches shadcn's value in both
220                // light and dark themes.
221                BubbleVariant::Secondary => this
222                    .bg(tokens.colors.muted)
223                    .text_color(tokens.colors.secondary_foreground),
224                BubbleVariant::Muted => this
225                    .bg(tokens.colors.muted)
226                    .text_color(tokens.colors.foreground),
227                BubbleVariant::Tinted => this
228                    .bg(tokens.colors.primary.mix_oklab(
229                        tokens.colors.background,
230                        if cx.theme().is_dark() { 0.24 } else { 0.12 },
231                    ))
232                    .text_color(tokens.colors.foreground),
233                BubbleVariant::Outline => this
234                    .border_color(tokens.colors.border)
235                    .bg(tokens.colors.background)
236                    .text_color(tokens.colors.foreground),
237                BubbleVariant::Ghost => this
238                    .rounded(tokens.radius.none)
239                    .border_0()
240                    .bg(cx.theme().transparent)
241                    .text_color(tokens.colors.foreground)
242                    .p_0(),
243                BubbleVariant::Destructive => this
244                    .bg(tokens.colors.destructive.opacity(if cx.theme().is_dark() {
245                        0.2
246                    } else {
247                        0.1
248                    }))
249                    .text_color(tokens.colors.destructive),
250            })
251            .refine_style(&self.style)
252            .children(self.children)
253    }
254}
255
256/// A vertical stack of consecutive bubbles from one sender.
257#[derive(IntoElement)]
258pub struct BubbleGroup {
259    style: StyleRefinement,
260    children: Vec<AnyElement>,
261}
262
263impl BubbleGroup {
264    /// Create an empty bubble group.
265    pub fn new() -> Self {
266        Self {
267            style: StyleRefinement::default(),
268            children: Vec::new(),
269        }
270    }
271}
272
273impl Default for BubbleGroup {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279impl ParentElement for BubbleGroup {
280    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
281        self.children.extend(elements);
282    }
283}
284
285impl Styled for BubbleGroup {
286    fn style(&mut self) -> &mut StyleRefinement {
287        &mut self.style
288    }
289}
290
291impl RenderOnce for BubbleGroup {
292    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
293        v_flex()
294            .min_w_0()
295            .gap_2()
296            .refine_style(&self.style)
297            .children(self.children)
298    }
299}
300
301enum BubbleReactionChild {
302    Action(Box<Button>),
303    Element(AnyElement),
304}
305
306/// A styleable reaction region positioned on a bubble edge.
307///
308/// Compose existing [`crate::button::Button`] values inside this region to
309/// preserve button semantics and keyboard behavior.
310#[derive(IntoElement)]
311pub struct BubbleReactions {
312    style: StyleRefinement,
313    side: BubbleReactionSide,
314    alignment: MessageAlignment,
315    children: Vec<BubbleReactionChild>,
316}
317
318impl BubbleReactions {
319    /// Create a trailing-aligned reaction region on the lower edge.
320    pub fn new() -> Self {
321        Self {
322            style: StyleRefinement::default(),
323            side: BubbleReactionSide::Bottom,
324            alignment: MessageAlignment::End,
325            children: Vec::new(),
326        }
327    }
328
329    /// Set the edge on which reactions are positioned.
330    pub fn side(mut self, side: BubbleReactionSide) -> Self {
331        self.side = side;
332        self
333    }
334
335    /// Set the reaction region alignment along the bubble edge.
336    pub fn alignment(mut self, alignment: MessageAlignment) -> Self {
337        self.alignment = alignment;
338        self
339    }
340
341    /// Add an interactive action that shares the reaction surface.
342    ///
343    /// Typed actions remove the reaction region's decorative content padding
344    /// and use the theme's full radius so the button reads as part of one
345    /// surface. The typed action owns that pill geometry; use `.child(...)`
346    /// for emoji, labels, and arbitrary elements when the child should retain
347    /// its own styling, including a custom button radius.
348    pub fn action(mut self, action: Button) -> Self {
349        self.children
350            .push(BubbleReactionChild::Action(Box::new(action)));
351        self
352    }
353}
354
355impl Default for BubbleReactions {
356    fn default() -> Self {
357        Self::new()
358    }
359}
360
361impl ParentElement for BubbleReactions {
362    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
363        self.children
364            .extend(elements.into_iter().map(BubbleReactionChild::Element));
365    }
366}
367
368impl Styled for BubbleReactions {
369    fn style(&mut self) -> &mut StyleRefinement {
370        &mut self.style
371    }
372}
373
374impl RenderOnce for BubbleReactions {
375    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
376        let tokens = cx.theme().semantic_tokens();
377        let has_action = self
378            .children
379            .iter()
380            .any(|child| matches!(child, BubbleReactionChild::Action(_)));
381        let action_radius = cx.theme().radius_full();
382        let children = self.children.into_iter().map(move |child| match child {
383            BubbleReactionChild::Action(action) => {
384                (*action).rounded(action_radius).into_any_element()
385            }
386            BubbleReactionChild::Element(element) => element,
387        });
388
389        div()
390            .absolute()
391            .flex()
392            .flex_none()
393            .items_center()
394            .justify_center()
395            .gap_1()
396            .rounded(cx.theme().radius_full())
397            .border_3()
398            .border_color(tokens.colors.background)
399            .bg(tokens.colors.muted)
400            .text_color(tokens.colors.foreground)
401            .when(!has_action, |this| this.px_1p5().py_0p5())
402            .text_sm()
403            // Approximates shadcn's `translate-y-3/4`: GPUI cannot offset by a
404            // fraction of the pill's own height, so this fixed value leaves
405            // about three quarters of the default pill outside the bubble.
406            .when(self.side == BubbleReactionSide::Top, |this| {
407                this.top(-rems(1.25))
408            })
409            .when(self.side == BubbleReactionSide::Bottom, |this| {
410                this.bottom(-rems(1.25))
411            })
412            .when(self.alignment == MessageAlignment::Start, |this| {
413                this.left_3()
414            })
415            .when(self.alignment == MessageAlignment::End, |this| {
416                this.right_3()
417            })
418            .refine_style(&self.style)
419            .children(children)
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn test_bubble_builder() {
429        let bubble = Bubble::new()
430            .alignment(MessageAlignment::End)
431            .with_variant(BubbleVariant::Outline)
432            .content(BubbleContent::new().child("Hello"))
433            .reactions(BubbleReactions::new().child("👍"));
434
435        assert_eq!(bubble.alignment, Some(MessageAlignment::End));
436        assert_eq!(bubble.variant, BubbleVariant::Outline);
437        assert_eq!(bubble.content.children.len(), 1);
438        assert!(bubble.reactions.is_some());
439
440        // Direct children survive a later `content(...)` call and stay in
441        // front of the new surface's own children.
442        let reordered = Bubble::new()
443            .child("Existing")
444            .content(BubbleContent::new().child("Configured"));
445        assert_eq!(reordered.content.children.len(), 2);
446
447        let group = BubbleGroup::new().child("First").child("Second");
448        assert_eq!(group.children.len(), 2);
449
450        let reactions = BubbleReactions::new()
451            .side(BubbleReactionSide::Top)
452            .alignment(MessageAlignment::Start)
453            .child("👍 2");
454
455        assert_eq!(reactions.side, BubbleReactionSide::Top);
456        assert_eq!(reactions.alignment, MessageAlignment::Start);
457        assert_eq!(reactions.children.len(), 1);
458
459        let reactions = BubbleReactions::new()
460            .action(Button::new("reaction-action"))
461            .child("👍");
462        assert_eq!(reactions.children.len(), 2);
463        assert!(matches!(
464            reactions.children.first(),
465            Some(BubbleReactionChild::Action(_))
466        ));
467        assert!(matches!(
468            reactions.children.get(1),
469            Some(BubbleReactionChild::Element(_))
470        ));
471    }
472}