Skip to main content

gpui_component/
marker.rs

1use crate::{
2    ActiveTheme as _, RoleOverride, Sizable as _, StyledExt as _, h_flex,
3    shimmer::{ShimmerStyle, ShimmerText},
4    spinner::Spinner,
5};
6use gpui::{
7    AnimationExt as _, AnyElement, App, ElementId, InteractiveElement as _, IntoElement,
8    ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
9    Styled, StyledText, Window, div, prelude::FluentBuilder as _, px, relative, rems,
10};
11
12/// The visual treatment used by a [`Marker`].
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum MarkerVariant {
15    /// An inline marker with no additional divider.
16    #[default]
17    Plain,
18    /// A centered marker with semantic divider lines on both sides.
19    Separator,
20    /// A marker with a semantic bottom border.
21    Border,
22}
23
24/// The visual treatment used while a [`Marker`] is loading.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum MarkerLoadingStyle {
27    /// Show a compact rotating spinner beside the marker content.
28    #[default]
29    Spinner,
30    /// Sweep a highlight across marker content without adding an icon.
31    Shimmer,
32}
33
34/// Horizontal placement of a [`Marker`]'s children inside its full-width row.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum MarkerAlignment {
37    /// Keep the children at the leading edge.
38    Start,
39    /// Center the children, like a system notice in a transcript.
40    Center,
41    /// Keep the children at the trailing edge.
42    End,
43}
44
45enum MarkerChild {
46    Icon(MarkerIcon),
47    Content(MarkerContent),
48    Element(AnyElement),
49}
50
51/// A compact, composable row for conversation status and system markers.
52///
53/// `Marker` intentionally accepts arbitrary children. An icon, text, spinner,
54/// or action can be composed directly without introducing fixed icon and
55/// content slots. Use [`Styled`] methods on the marker to refine its layout or
56/// typography for an application-specific use. Loading effects only affect
57/// configured content slots, so icons and separators retain their appearance.
58#[derive(IntoElement)]
59pub struct Marker {
60    id: Option<ElementId>,
61    style: StyleRefinement,
62    separator_style: StyleRefinement,
63    variant: MarkerVariant,
64    alignment: Option<MarkerAlignment>,
65    loading: bool,
66    loading_style: MarkerLoadingStyle,
67    shimmer_style: ShimmerStyle,
68    role: RoleOverride,
69    children: Vec<MarkerChild>,
70}
71
72impl Marker {
73    /// Create a plain marker.
74    pub fn new() -> Self {
75        Self {
76            id: None,
77            style: StyleRefinement::default(),
78            separator_style: StyleRefinement::default(),
79            variant: MarkerVariant::default(),
80            alignment: None,
81            loading: false,
82            loading_style: MarkerLoadingStyle::default(),
83            shimmer_style: ShimmerStyle::default(),
84            role: RoleOverride::default(),
85            children: Vec::new(),
86        }
87    }
88
89    /// Set a stable identity so the marker can appear in the accessibility tree.
90    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
91        self.id = Some(id.into());
92        self
93    }
94
95    /// Set the accessibility role announced for this marker.
96    ///
97    /// A marker is presentational by default. Set [`gpui::Role::Status`] on a
98    /// row that reports streaming or loading progress so assistive technology
99    /// announces its updates. Accessibility nodes need a stable identity, so
100    /// the role takes effect only together with [`Self::id`].
101    pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
102        self.role = role.into();
103        self
104    }
105
106    /// Set the visual treatment of the marker.
107    pub fn with_variant(mut self, variant: MarkerVariant) -> Self {
108        self.variant = variant;
109        self
110    }
111
112    /// Set where the row places its children.
113    ///
114    /// Unset, a [`MarkerVariant::Separator`] centers its label between the two
115    /// lines and every other variant starts at the leading edge. An explicit
116    /// alignment applies to any variant and also aligns wrapped text lines. A
117    /// separator keeps only the line on the far side of its label, so `Start`
118    /// draws the trailing line and `End` the leading one.
119    pub fn alignment(mut self, alignment: MarkerAlignment) -> Self {
120        self.alignment = Some(alignment);
121        self
122    }
123
124    fn resolved_alignment(&self) -> MarkerAlignment {
125        self.alignment.unwrap_or(match self.variant {
126            MarkerVariant::Separator => MarkerAlignment::Center,
127            MarkerVariant::Plain | MarkerVariant::Border => MarkerAlignment::Start,
128        })
129    }
130
131    /// Set whether the marker should display its configured loading effect.
132    pub fn loading(mut self, loading: bool) -> Self {
133        self.loading = loading;
134        self
135    }
136
137    /// Set the visual treatment used when [`Self::loading`] is enabled.
138    pub fn with_loading_style(mut self, loading_style: MarkerLoadingStyle) -> Self {
139        self.loading_style = loading_style;
140        self
141    }
142
143    /// Configure the text highlight used by [`MarkerLoadingStyle::Shimmer`].
144    pub fn with_shimmer_style(mut self, shimmer_style: ShimmerStyle) -> Self {
145        self.shimmer_style = shimmer_style;
146        self
147    }
148
149    /// Refine the decorative lines used by [`MarkerVariant::Separator`].
150    pub fn separator_style(mut self, style: StyleRefinement) -> Self {
151        self.separator_style = style;
152        self
153    }
154
155    /// Add a configured icon slot.
156    pub fn icon(mut self, icon: MarkerIcon) -> Self {
157        self.children.push(MarkerChild::Icon(icon));
158        self
159    }
160
161    /// Add a configured content slot.
162    pub fn content(mut self, content: MarkerContent) -> Self {
163        self.children.push(MarkerChild::Content(content));
164        self
165    }
166}
167
168impl Default for Marker {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174impl ParentElement for Marker {
175    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
176        self.children
177            .extend(elements.into_iter().map(MarkerChild::Element));
178    }
179}
180
181impl Styled for Marker {
182    fn style(&mut self) -> &mut StyleRefinement {
183        &mut self.style
184    }
185}
186
187impl RenderOnce for Marker {
188    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
189        let tokens = cx.theme().semantic_tokens();
190        let variant = self.variant;
191        let alignment = self.resolved_alignment();
192        let loading = self.loading;
193        let loading_style = self.loading_style;
194        let shimmer_style = self.shimmer_style;
195        let has_icon = self
196            .children
197            .iter()
198            .any(|child| matches!(child, MarkerChild::Icon(_)));
199        let role = self.role;
200        let separator_style = self.separator_style;
201        let children = self.children.into_iter().map(move |child| match child {
202            MarkerChild::Icon(icon) => icon.into_any_element(),
203            MarkerChild::Content(mut content) => {
204                content.shimmer = loading && loading_style == MarkerLoadingStyle::Shimmer;
205                content.shimmer_style = shimmer_style;
206                content.separator = variant == MarkerVariant::Separator;
207                content.alignment = alignment;
208                content.into_any_element()
209            }
210            MarkerChild::Element(element) => element,
211        });
212
213        let row = h_flex()
214            .w_full()
215            .min_h(rems(1.))
216            .gap_2()
217            .text_sm()
218            .line_height(relative(1.5))
219            .text_color(tokens.colors.muted_foreground)
220            .map(|this| match alignment {
221                MarkerAlignment::Start => this.justify_start().text_left(),
222                MarkerAlignment::Center => this.justify_center().text_center(),
223                MarkerAlignment::End => this.justify_end().text_right(),
224            })
225            .when(variant == MarkerVariant::Border, |this| {
226                this.border_b_1().border_color(tokens.colors.border).pb_2()
227            })
228            .when(
229                variant == MarkerVariant::Separator && alignment != MarkerAlignment::Start,
230                |this| {
231                    this.child(
232                        div()
233                            .flex_1()
234                            .min_w_0()
235                            .h(px(1.))
236                            .mr_1()
237                            .bg(tokens.colors.border)
238                            .refine_style(&separator_style),
239                    )
240                },
241            )
242            .when(
243                loading && loading_style == MarkerLoadingStyle::Spinner && !has_icon,
244                |this| this.child(MarkerIcon::new().child(Spinner::new().xsmall())),
245            )
246            .children(children)
247            .when(
248                variant == MarkerVariant::Separator && alignment != MarkerAlignment::End,
249                |this| {
250                    this.child(
251                        div()
252                            .flex_1()
253                            .min_w_0()
254                            .h(px(1.))
255                            .ml_1()
256                            .bg(tokens.colors.border)
257                            .refine_style(&separator_style),
258                    )
259                },
260            )
261            .refine_style(&self.style);
262
263        // `role` lives on the stateful element: accessibility nodes need the
264        // stable identity that only an element id provides.
265        match (self.id, role) {
266            (Some(id), RoleOverride::Role(role)) => row.id(id).role(role).into_any_element(),
267            (Some(id), _) => row.id(id).into_any_element(),
268            (None, _) => row.into_any_element(),
269        }
270    }
271}
272
273/// A compact decorative icon slot inside a [`Marker`].
274#[derive(IntoElement)]
275pub struct MarkerIcon {
276    style: StyleRefinement,
277    children: Vec<AnyElement>,
278}
279
280impl MarkerIcon {
281    /// Create an empty marker icon slot.
282    pub fn new() -> Self {
283        Self {
284            style: StyleRefinement::default(),
285            children: Vec::new(),
286        }
287    }
288}
289
290impl Default for MarkerIcon {
291    fn default() -> Self {
292        Self::new()
293    }
294}
295
296impl ParentElement for MarkerIcon {
297    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
298        self.children.extend(elements);
299    }
300}
301
302impl Styled for MarkerIcon {
303    fn style(&mut self) -> &mut StyleRefinement {
304        &mut self.style
305    }
306}
307
308impl RenderOnce for MarkerIcon {
309    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
310        h_flex()
311            .size_4()
312            .flex_none()
313            .items_center()
314            .justify_center()
315            .refine_style(&self.style)
316            .children(self.children)
317    }
318}
319
320/// The independently styleable text or rich-content slot in a [`Marker`].
321#[derive(IntoElement)]
322pub struct MarkerContent {
323    style: StyleRefinement,
324    shimmer: bool,
325    shimmer_style: ShimmerStyle,
326    separator: bool,
327    alignment: MarkerAlignment,
328    children: Vec<MarkerContentChild>,
329}
330
331enum MarkerContentChild {
332    Text(SharedString),
333    Element(AnyElement),
334}
335
336impl MarkerContent {
337    /// Create an empty marker content slot.
338    pub fn new() -> Self {
339        Self {
340            style: StyleRefinement::default(),
341            shimmer: false,
342            shimmer_style: ShimmerStyle::default(),
343            separator: false,
344            alignment: MarkerAlignment::Start,
345            children: Vec::new(),
346        }
347    }
348
349    /// Add text that can receive a continuous loading shimmer.
350    ///
351    /// Arbitrary children remain supported through [`ParentElement`].
352    pub fn text(mut self, text: impl Into<SharedString>) -> Self {
353        self.children.push(MarkerContentChild::Text(text.into()));
354        self
355    }
356}
357
358impl Default for MarkerContent {
359    fn default() -> Self {
360        Self::new()
361    }
362}
363
364impl ParentElement for MarkerContent {
365    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
366        self.children
367            .extend(elements.into_iter().map(MarkerContentChild::Element));
368    }
369}
370
371impl Styled for MarkerContent {
372    fn style(&mut self) -> &mut StyleRefinement {
373        &mut self.style
374    }
375}
376
377impl RenderOnce for MarkerContent {
378    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
379        let animate = self.shimmer && !cx.reduce_motion();
380        let has_text = self
381            .children
382            .iter()
383            .any(|child| matches!(child, MarkerContentChild::Text(_)));
384        let base_opacity = self.style.opacity.unwrap_or(1.);
385        let shimmer_style = self.shimmer_style;
386        let children =
387            self.children
388                .into_iter()
389                .enumerate()
390                .map(move |(index, child)| match child {
391                    MarkerContentChild::Text(text) if animate => ShimmerText::new(text)
392                        .id(("marker-loading-text", index))
393                        .with_shimmer_style(shimmer_style)
394                        .into_any_element(),
395                    MarkerContentChild::Text(text) => StyledText::new(text).into_any_element(),
396                    MarkerContentChild::Element(element) => element,
397                });
398
399        let content = div()
400            .min_w_0()
401            // Between separator lines the label keeps its own width so the
402            // lines take the rest; elsewhere it may shrink and wrap.
403            .when(self.separator, |this| this.flex_none())
404            .map(|this| match self.alignment {
405                MarkerAlignment::Start => this.text_left(),
406                MarkerAlignment::Center => this.text_center(),
407                MarkerAlignment::End => this.text_right(),
408            })
409            .refine_style(&self.style)
410            .children(children);
411
412        if animate && !has_text {
413            content
414                .with_animation(
415                    "marker-loading-content",
416                    shimmer_style.animation(),
417                    move |this, phase| {
418                        let highlight = (phase * std::f32::consts::TAU).cos().mul_add(0.5, 0.5);
419                        this.opacity(base_opacity * highlight.mul_add(0.4, 0.6))
420                    },
421                )
422                .into_any_element()
423        } else {
424            content.into_any_element()
425        }
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn test_marker_builder() {
435        let marker = Marker::new()
436            .with_variant(MarkerVariant::Separator)
437            .loading(true)
438            .with_loading_style(MarkerLoadingStyle::Shimmer)
439            .with_shimmer_style(ShimmerStyle::new().reverse(true))
440            .separator_style(StyleRefinement::default())
441            .content(MarkerContent::new().child("Today"));
442
443        assert_eq!(marker.variant, MarkerVariant::Separator);
444        assert!(marker.loading);
445        assert_eq!(marker.loading_style, MarkerLoadingStyle::Shimmer);
446        assert_eq!(marker.children.len(), 1);
447        assert_eq!(Marker::default().variant, MarkerVariant::Plain);
448        assert!(Marker::default().alignment.is_none());
449        assert!(!Marker::default().loading);
450        assert_eq!(Marker::default().loading_style, MarkerLoadingStyle::Spinner);
451
452        let centered = Marker::new().alignment(MarkerAlignment::Center);
453        assert_eq!(centered.alignment, Some(MarkerAlignment::Center));
454
455        let content_first = Marker::new()
456            .content(MarkerContent::new().text("Thinking"))
457            .with_loading_style(MarkerLoadingStyle::Shimmer)
458            .loading(true);
459        assert!(content_first.loading);
460        assert_eq!(content_first.loading_style, MarkerLoadingStyle::Shimmer);
461        assert!(matches!(
462            &content_first.children[0],
463            MarkerChild::Content(_)
464        ));
465
466        let custom_icon = Marker::new()
467            .loading(true)
468            .icon(MarkerIcon::new().child("custom"))
469            .content(MarkerContent::new().text("Loading"));
470        assert_eq!(custom_icon.children.len(), 2);
471        assert!(matches!(&custom_icon.children[0], MarkerChild::Icon(_)));
472
473        assert_eq!(Marker::default().role, RoleOverride::default());
474        assert!(Marker::default().id.is_none());
475        let status = Marker::new().id("sync-status").role(gpui::Role::Status);
476        assert_eq!(status.id, Some("sync-status".into()));
477        assert_eq!(status.role, RoleOverride::Role(gpui::Role::Status));
478
479        let styled = Marker::new().opacity(0.37).child("Status").child("Details");
480
481        assert_eq!(styled.style.opacity, Some(0.37));
482        assert_eq!(styled.children.len(), 2);
483
484        let icon = MarkerIcon::new().child("icon");
485        assert_eq!(icon.children.len(), 1);
486
487        let content = MarkerContent::new()
488            .text("Thinking")
489            .child("…")
490            .text("正在思考");
491        assert_eq!(content.children.len(), 3);
492        assert!(matches!(&content.children[0], MarkerContentChild::Text(_)));
493        assert!(matches!(
494            &content.children[1],
495            MarkerContentChild::Element(_)
496        ));
497        assert!(matches!(&content.children[2], MarkerContentChild::Text(_)));
498    }
499
500    #[test]
501    fn test_marker_resolved_alignment() {
502        // Unset: only the separator centers its label.
503        assert_eq!(Marker::new().resolved_alignment(), MarkerAlignment::Start);
504        assert_eq!(
505            Marker::new()
506                .with_variant(MarkerVariant::Border)
507                .resolved_alignment(),
508            MarkerAlignment::Start
509        );
510        assert_eq!(
511            Marker::new()
512                .with_variant(MarkerVariant::Separator)
513                .resolved_alignment(),
514            MarkerAlignment::Center
515        );
516
517        // Explicit alignment wins over the variant default, in either order.
518        assert_eq!(
519            Marker::new()
520                .alignment(MarkerAlignment::End)
521                .with_variant(MarkerVariant::Separator)
522                .resolved_alignment(),
523            MarkerAlignment::End
524        );
525        assert_eq!(
526            Marker::new()
527                .with_variant(MarkerVariant::Plain)
528                .alignment(MarkerAlignment::Center)
529                .resolved_alignment(),
530            MarkerAlignment::Center
531        );
532    }
533}