Skip to main content

gpui_component/
message.rs

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