Skip to main content

gpui_component/
message.rs

1use gpui::{
2    AnyElement, App, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window,
3    prelude::FluentBuilder as _, relative, rems,
4};
5
6use crate::{ActiveTheme as _, StyledExt as _, bubble::Bubble, h_flex, v_flex};
7
8/// Horizontal alignment for a message and message-owned chat surfaces.
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum MessageAlignment {
11    /// Place the message at the leading edge.
12    #[default]
13    Start,
14    /// Place the message at the trailing edge.
15    End,
16}
17
18/// A vertical stack of consecutive messages from the same sender.
19#[derive(IntoElement)]
20pub struct MessageGroup {
21    style: StyleRefinement,
22    children: Vec<AnyElement>,
23}
24
25impl MessageGroup {
26    /// Create an empty message group.
27    pub fn new() -> Self {
28        Self {
29            style: StyleRefinement::default(),
30            children: Vec::new(),
31        }
32    }
33}
34
35impl Default for MessageGroup {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl ParentElement for MessageGroup {
42    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
43        self.children.extend(elements);
44    }
45}
46
47impl Styled for MessageGroup {
48    fn style(&mut self) -> &mut StyleRefinement {
49        &mut self.style
50    }
51}
52
53impl RenderOnce for MessageGroup {
54    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
55        v_flex()
56            .min_w_0()
57            .gap_2()
58            .refine_style(&self.style)
59            .children(self.children)
60    }
61}
62
63/// A composable message row with named avatar, header, content, and footer slots.
64///
65/// Named slots let the message apply its alignment consistently while every
66/// part remains independently styleable.
67#[derive(IntoElement)]
68pub struct Message {
69    style: StyleRefinement,
70    stack_style: StyleRefinement,
71    alignment: MessageAlignment,
72    avatar: Option<MessageAvatar>,
73    header: Option<MessageHeader>,
74    content: Option<MessageContent>,
75    footer: Option<MessageFooter>,
76}
77
78impl Message {
79    /// Create a leading-aligned message.
80    pub fn new() -> Self {
81        Self {
82            style: StyleRefinement::default(),
83            stack_style: StyleRefinement::default(),
84            alignment: MessageAlignment::Start,
85            avatar: None,
86            header: None,
87            content: None,
88            footer: None,
89        }
90    }
91
92    /// Set whether the message is aligned to the leading or trailing edge.
93    pub fn alignment(mut self, alignment: MessageAlignment) -> Self {
94        self.alignment = alignment;
95        self
96    }
97
98    /// Refine the inner vertical stack that contains the named slots.
99    pub fn with_stack_style(mut self, style: StyleRefinement) -> Self {
100        self.stack_style = style;
101        self
102    }
103
104    /// Set an optional avatar or other sender identity element.
105    pub fn avatar(mut self, avatar: impl IntoElement) -> Self {
106        self.avatar = Some(MessageAvatar::new().child(avatar));
107        self
108    }
109
110    /// Set a fully configured avatar slot.
111    pub fn avatar_slot(mut self, avatar: MessageAvatar) -> Self {
112        self.avatar = Some(avatar);
113        self
114    }
115
116    /// Set the message header.
117    pub fn header(mut self, header: MessageHeader) -> Self {
118        self.header = Some(header);
119        self
120    }
121
122    /// Set the message body.
123    pub fn content(mut self, content: MessageContent) -> Self {
124        self.content = Some(content);
125        self
126    }
127
128    /// Set the message footer.
129    pub fn footer(mut self, footer: MessageFooter) -> Self {
130        self.footer = Some(footer);
131        self
132    }
133}
134
135impl Default for Message {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl Styled for Message {
142    fn style(&mut self) -> &mut StyleRefinement {
143        &mut self.style
144    }
145}
146
147impl RenderOnce for Message {
148    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
149        let alignment = self.alignment;
150        let has_avatar = self.avatar.is_some();
151        let has_ghost_bubble = self
152            .content
153            .as_ref()
154            .is_some_and(|content| content.has_ghost_bubble);
155        let stack_style = self.stack_style;
156
157        v_flex()
158            .relative()
159            .w_full()
160            .min_w_0()
161            .gap(rems(0.625))
162            .text_sm()
163            .line_height(relative(1.25))
164            .map(|this| match alignment {
165                MessageAlignment::Start => this.items_start(),
166                MessageAlignment::End => this.items_end(),
167            })
168            .refine_style(&self.style)
169            .child(
170                // The footer lives outside this row so the bottom-anchored
171                // avatar always sits flush with the content's bottom edge,
172                // whatever the footer contains.
173                h_flex()
174                    .w_full()
175                    .min_w_0()
176                    .items_end()
177                    .gap_2()
178                    .when(alignment == MessageAlignment::End, |this| {
179                        this.flex_row_reverse()
180                    })
181                    .when_some(self.avatar, |this, avatar| this.child(avatar))
182                    .child(
183                        v_flex()
184                            .w_full()
185                            .min_w_0()
186                            .gap(rems(0.625))
187                            .map(|this| match alignment {
188                                MessageAlignment::Start => this.items_start(),
189                                MessageAlignment::End => this.items_end(),
190                            })
191                            .refine_style(&stack_style)
192                            .when_some(self.header, |this, header| {
193                                this.child(header.with_inherited_content_inset(!has_ghost_bubble))
194                            })
195                            .when_some(self.content, |this, content| {
196                                this.child(content.aligned(alignment))
197                            }),
198                    ),
199            )
200            .when_some(self.footer, |this, footer| {
201                this.child(
202                    footer
203                        .with_inherited_content_inset(!has_ghost_bubble)
204                        // Align the footer with the content column: the
205                        // avatar's shared `size-8` baseline plus the row gap.
206                        .when(has_avatar && alignment == MessageAlignment::Start, |this| {
207                            this.ml(rems(2.5))
208                        })
209                        .when(has_avatar && alignment == MessageAlignment::End, |this| {
210                            this.mr(rems(2.5))
211                        }),
212                )
213            })
214    }
215}
216
217/// The sender identity slot rendered beside a [`Message`].
218///
219/// The slot reserves the shared `size-8` baseline; the message row keeps it
220/// flush with the bottom edge of the visible message surface.
221#[derive(IntoElement)]
222pub struct MessageAvatar {
223    style: StyleRefinement,
224    children: Vec<AnyElement>,
225}
226
227impl MessageAvatar {
228    /// Create an empty avatar slot.
229    pub fn new() -> Self {
230        Self {
231            style: StyleRefinement::default(),
232            children: Vec::new(),
233        }
234    }
235}
236
237impl Default for MessageAvatar {
238    fn default() -> Self {
239        Self::new()
240    }
241}
242
243impl ParentElement for MessageAvatar {
244    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
245        self.children.extend(elements);
246    }
247}
248
249impl Styled for MessageAvatar {
250    fn style(&mut self) -> &mut StyleRefinement {
251        &mut self.style
252    }
253}
254
255impl RenderOnce for MessageAvatar {
256    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
257        let tokens = cx.theme().semantic_tokens();
258
259        h_flex()
260            .relative()
261            .min_w_8()
262            .flex_none()
263            .items_center()
264            .justify_center()
265            .self_end()
266            .overflow_hidden()
267            .rounded(cx.theme().radius_full())
268            .bg(tokens.colors.muted)
269            .refine_style(&self.style)
270            .children(self.children)
271    }
272}
273
274/// Header content such as a sender name and timestamp.
275#[derive(IntoElement)]
276pub struct MessageHeader {
277    style: StyleRefinement,
278    content_inset: Option<bool>,
279    children: Vec<AnyElement>,
280}
281
282impl MessageHeader {
283    /// Create an empty message header.
284    pub fn new() -> Self {
285        Self {
286            style: StyleRefinement::default(),
287            content_inset: None,
288            children: Vec::new(),
289        }
290    }
291
292    /// Set whether the header keeps its default horizontal content inset.
293    pub fn content_inset(mut self, content_inset: bool) -> Self {
294        self.content_inset = Some(content_inset);
295        self
296    }
297
298    fn with_inherited_content_inset(mut self, content_inset: bool) -> Self {
299        self.content_inset.get_or_insert(content_inset);
300        self
301    }
302}
303
304impl Default for MessageHeader {
305    fn default() -> Self {
306        Self::new()
307    }
308}
309
310impl ParentElement for MessageHeader {
311    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
312        self.children.extend(elements);
313    }
314}
315
316impl Styled for MessageHeader {
317    fn style(&mut self) -> &mut StyleRefinement {
318        &mut self.style
319    }
320}
321
322impl RenderOnce for MessageHeader {
323    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
324        let tokens = cx.theme().semantic_tokens();
325
326        h_flex()
327            .max_w_full()
328            .min_w_0()
329            .gap_1()
330            .text_xs()
331            .line_height(relative(1.25))
332            .font_medium()
333            .text_color(tokens.colors.muted_foreground)
334            .when(self.content_inset.unwrap_or(true), |this| this.px_3())
335            .refine_style(&self.style)
336            .children(self.children)
337    }
338}
339
340/// The message body slot. It can contain bubbles, images, code, or files.
341#[derive(IntoElement)]
342pub struct MessageContent {
343    style: StyleRefinement,
344    alignment: MessageAlignment,
345    has_ghost_bubble: bool,
346    children: Vec<AnyElement>,
347}
348
349impl MessageContent {
350    /// Create an empty message body.
351    pub fn new() -> Self {
352        Self {
353            style: StyleRefinement::default(),
354            alignment: MessageAlignment::Start,
355            has_ghost_bubble: false,
356            children: Vec::new(),
357        }
358    }
359
360    /// Add a typed bubble and inherit ghost-surface metadata layout.
361    ///
362    /// Ordinary `.child(...)` content remains available for arbitrary elements;
363    /// use this builder when surrounding message slots should react to a
364    /// bubble's variant.
365    pub fn bubble(mut self, bubble: Bubble) -> Self {
366        self.has_ghost_bubble |= bubble.is_ghost();
367        self.children.push(bubble.into_any_element());
368        self
369    }
370
371    fn aligned(mut self, alignment: MessageAlignment) -> Self {
372        self.alignment = alignment;
373        self
374    }
375}
376
377impl Default for MessageContent {
378    fn default() -> Self {
379        Self::new()
380    }
381}
382
383impl ParentElement for MessageContent {
384    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
385        self.children.extend(elements);
386    }
387}
388
389impl Styled for MessageContent {
390    fn style(&mut self) -> &mut StyleRefinement {
391        &mut self.style
392    }
393}
394
395impl RenderOnce for MessageContent {
396    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
397        v_flex()
398            .w_full()
399            .max_w_full()
400            .min_w_0()
401            .gap(rems(0.625))
402            .map(|this| match self.alignment {
403                MessageAlignment::Start => this.items_start(),
404                MessageAlignment::End => this.items_end(),
405            })
406            .refine_style(&self.style)
407            .children(self.children)
408    }
409}
410
411/// Footer content such as delivery state, reactions, or action buttons.
412#[derive(IntoElement)]
413pub struct MessageFooter {
414    style: StyleRefinement,
415    content_inset: Option<bool>,
416    children: Vec<AnyElement>,
417}
418
419impl MessageFooter {
420    /// Create an empty message footer.
421    pub fn new() -> Self {
422        Self {
423            style: StyleRefinement::default(),
424            content_inset: None,
425            children: Vec::new(),
426        }
427    }
428
429    /// Set whether the footer keeps its default horizontal content inset.
430    pub fn content_inset(mut self, content_inset: bool) -> Self {
431        self.content_inset = Some(content_inset);
432        self
433    }
434
435    fn with_inherited_content_inset(mut self, content_inset: bool) -> Self {
436        self.content_inset.get_or_insert(content_inset);
437        self
438    }
439}
440
441impl Default for MessageFooter {
442    fn default() -> Self {
443        Self::new()
444    }
445}
446
447impl ParentElement for MessageFooter {
448    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
449        self.children.extend(elements);
450    }
451}
452
453impl Styled for MessageFooter {
454    fn style(&mut self) -> &mut StyleRefinement {
455        &mut self.style
456    }
457}
458
459impl RenderOnce for MessageFooter {
460    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
461        let tokens = cx.theme().semantic_tokens();
462
463        h_flex()
464            .max_w_full()
465            .min_w_0()
466            .gap_1()
467            .text_xs()
468            .line_height(relative(1.25))
469            .font_medium()
470            .text_color(tokens.colors.muted_foreground)
471            .when(self.content_inset.unwrap_or(true), |this| this.px_3())
472            .refine_style(&self.style)
473            .children(self.children)
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn test_message_builder() {
483        let stack_style = StyleRefinement::default().gap_1();
484        let message = Message::new()
485            .alignment(MessageAlignment::End)
486            .with_stack_style(stack_style.clone())
487            .avatar_slot(MessageAvatar::new().child(gpui::div()))
488            .header(MessageHeader::new().content_inset(false).child("Alice"))
489            .content(MessageContent::new().child("Hello"))
490            .footer(MessageFooter::new().content_inset(false).child("Delivered"));
491
492        assert_eq!(message.alignment, MessageAlignment::End);
493        assert_eq!(message.stack_style, stack_style);
494        assert!(message.avatar.is_some());
495        assert!(message.header.is_some());
496        assert!(message.content.is_some());
497        assert!(message.footer.is_some());
498        assert_eq!(message.header.as_ref().unwrap().content_inset, Some(false));
499        assert_eq!(message.footer.as_ref().unwrap().content_inset, Some(false));
500
501        let group = MessageGroup::new().child("First").child("Second");
502        assert_eq!(group.children.len(), 2);
503
504        let content = MessageContent::new().aligned(MessageAlignment::End);
505        assert_eq!(content.alignment, MessageAlignment::End);
506
507        let avatar = MessageAvatar::new().child("ME");
508        assert_eq!(avatar.children.len(), 1);
509    }
510
511    #[test]
512    fn test_ghost_bubble_inherits_message_slot_insets() {
513        let content = MessageContent::new()
514            .bubble(Bubble::new())
515            .bubble(Bubble::new().with_variant(crate::bubble::BubbleVariant::Ghost));
516
517        assert!(content.has_ghost_bubble);
518        assert_eq!(content.children.len(), 2);
519        assert_eq!(
520            MessageHeader::new()
521                .with_inherited_content_inset(false)
522                .content_inset,
523            Some(false)
524        );
525        assert_eq!(
526            MessageFooter::new()
527                .with_inherited_content_inset(false)
528                .content_inset,
529            Some(false)
530        );
531        assert_eq!(
532            MessageHeader::new()
533                .content_inset(true)
534                .with_inherited_content_inset(false)
535                .content_inset,
536            Some(true)
537        );
538        assert_eq!(
539            MessageFooter::new()
540                .content_inset(true)
541                .with_inherited_content_inset(false)
542                .content_inset,
543            Some(true)
544        );
545    }
546}