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
34enum MarkerChild {
35    Icon(MarkerIcon),
36    Content(MarkerContent),
37    Element(AnyElement),
38}
39
40/// A compact, composable row for conversation status and system markers.
41///
42/// `Marker` intentionally accepts arbitrary children. An icon, text, spinner,
43/// or action can be composed directly without introducing fixed icon and
44/// content slots. Use [`Styled`] methods on the marker to refine its layout or
45/// typography for an application-specific use. Loading effects only affect
46/// configured content slots, so icons and separators retain their appearance.
47#[derive(IntoElement)]
48pub struct Marker {
49    id: Option<ElementId>,
50    style: StyleRefinement,
51    separator_style: StyleRefinement,
52    variant: MarkerVariant,
53    loading: bool,
54    loading_style: MarkerLoadingStyle,
55    shimmer_style: ShimmerStyle,
56    role: RoleOverride,
57    children: Vec<MarkerChild>,
58}
59
60impl Marker {
61    /// Create a plain marker.
62    pub fn new() -> Self {
63        Self {
64            id: None,
65            style: StyleRefinement::default(),
66            separator_style: StyleRefinement::default(),
67            variant: MarkerVariant::default(),
68            loading: false,
69            loading_style: MarkerLoadingStyle::default(),
70            shimmer_style: ShimmerStyle::default(),
71            role: RoleOverride::default(),
72            children: Vec::new(),
73        }
74    }
75
76    /// Set a stable identity so the marker can appear in the accessibility tree.
77    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
78        self.id = Some(id.into());
79        self
80    }
81
82    /// Set the accessibility role announced for this marker.
83    ///
84    /// A marker is presentational by default. Set [`gpui::Role::Status`] on a
85    /// row that reports streaming or loading progress so assistive technology
86    /// announces its updates. Accessibility nodes need a stable identity, so
87    /// the role takes effect only together with [`Self::id`].
88    pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
89        self.role = role.into();
90        self
91    }
92
93    /// Set the visual treatment of the marker.
94    pub fn with_variant(mut self, variant: MarkerVariant) -> Self {
95        self.variant = variant;
96        self
97    }
98
99    /// Set whether the marker should display its configured loading effect.
100    pub fn loading(mut self, loading: bool) -> Self {
101        self.loading = loading;
102        self
103    }
104
105    /// Set the visual treatment used when [`Self::loading`] is enabled.
106    pub fn with_loading_style(mut self, loading_style: MarkerLoadingStyle) -> Self {
107        self.loading_style = loading_style;
108        self
109    }
110
111    /// Configure the text highlight used by [`MarkerLoadingStyle::Shimmer`].
112    pub fn with_shimmer_style(mut self, shimmer_style: ShimmerStyle) -> Self {
113        self.shimmer_style = shimmer_style;
114        self
115    }
116
117    /// Refine the decorative lines used by [`MarkerVariant::Separator`].
118    pub fn separator_style(mut self, style: StyleRefinement) -> Self {
119        self.separator_style = style;
120        self
121    }
122
123    /// Add a configured icon slot.
124    pub fn icon(mut self, icon: MarkerIcon) -> Self {
125        self.children.push(MarkerChild::Icon(icon));
126        self
127    }
128
129    /// Add a configured content slot.
130    pub fn content(mut self, content: MarkerContent) -> Self {
131        self.children.push(MarkerChild::Content(content));
132        self
133    }
134}
135
136impl Default for Marker {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142impl ParentElement for Marker {
143    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
144        self.children
145            .extend(elements.into_iter().map(MarkerChild::Element));
146    }
147}
148
149impl Styled for Marker {
150    fn style(&mut self) -> &mut StyleRefinement {
151        &mut self.style
152    }
153}
154
155impl RenderOnce for Marker {
156    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
157        let tokens = cx.theme().semantic_tokens();
158        let variant = self.variant;
159        let loading = self.loading;
160        let loading_style = self.loading_style;
161        let shimmer_style = self.shimmer_style;
162        let has_icon = self
163            .children
164            .iter()
165            .any(|child| matches!(child, MarkerChild::Icon(_)));
166        let role = self.role;
167        let separator_style = self.separator_style;
168        let children = self.children.into_iter().map(move |child| match child {
169            MarkerChild::Icon(icon) => icon.into_any_element(),
170            MarkerChild::Content(mut content) => {
171                content.shimmer = loading && loading_style == MarkerLoadingStyle::Shimmer;
172                content.shimmer_style = shimmer_style;
173                content.separator = variant == MarkerVariant::Separator;
174                content.into_any_element()
175            }
176            MarkerChild::Element(element) => element,
177        });
178
179        let row = h_flex()
180            .w_full()
181            .min_h(rems(1.))
182            .gap_2()
183            .text_sm()
184            .line_height(relative(1.5))
185            .text_color(tokens.colors.muted_foreground)
186            .text_left()
187            .when(variant == MarkerVariant::Separator, |this| {
188                this.justify_center()
189            })
190            .when(variant == MarkerVariant::Border, |this| {
191                this.border_b_1().border_color(tokens.colors.border).pb_2()
192            })
193            .when(variant == MarkerVariant::Separator, |this| {
194                this.child(
195                    div()
196                        .flex_1()
197                        .min_w_0()
198                        .h(px(1.))
199                        .mr_1()
200                        .bg(tokens.colors.border)
201                        .refine_style(&separator_style),
202                )
203            })
204            .when(
205                loading && loading_style == MarkerLoadingStyle::Spinner && !has_icon,
206                |this| this.child(MarkerIcon::new().child(Spinner::new().xsmall())),
207            )
208            .children(children)
209            .when(variant == MarkerVariant::Separator, |this| {
210                this.child(
211                    div()
212                        .flex_1()
213                        .min_w_0()
214                        .h(px(1.))
215                        .ml_1()
216                        .bg(tokens.colors.border)
217                        .refine_style(&separator_style),
218                )
219            })
220            .refine_style(&self.style);
221
222        // `role` lives on the stateful element: accessibility nodes need the
223        // stable identity that only an element id provides.
224        match (self.id, role) {
225            (Some(id), RoleOverride::Role(role)) => row.id(id).role(role).into_any_element(),
226            (Some(id), _) => row.id(id).into_any_element(),
227            (None, _) => row.into_any_element(),
228        }
229    }
230}
231
232/// A compact decorative icon slot inside a [`Marker`].
233#[derive(IntoElement)]
234pub struct MarkerIcon {
235    style: StyleRefinement,
236    children: Vec<AnyElement>,
237}
238
239impl MarkerIcon {
240    /// Create an empty marker icon slot.
241    pub fn new() -> Self {
242        Self {
243            style: StyleRefinement::default(),
244            children: Vec::new(),
245        }
246    }
247}
248
249impl Default for MarkerIcon {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255impl ParentElement for MarkerIcon {
256    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
257        self.children.extend(elements);
258    }
259}
260
261impl Styled for MarkerIcon {
262    fn style(&mut self) -> &mut StyleRefinement {
263        &mut self.style
264    }
265}
266
267impl RenderOnce for MarkerIcon {
268    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
269        h_flex()
270            .size_4()
271            .flex_none()
272            .items_center()
273            .justify_center()
274            .refine_style(&self.style)
275            .children(self.children)
276    }
277}
278
279/// The independently styleable text or rich-content slot in a [`Marker`].
280#[derive(IntoElement)]
281pub struct MarkerContent {
282    style: StyleRefinement,
283    shimmer: bool,
284    shimmer_style: ShimmerStyle,
285    separator: bool,
286    children: Vec<MarkerContentChild>,
287}
288
289enum MarkerContentChild {
290    Text(SharedString),
291    Element(AnyElement),
292}
293
294impl MarkerContent {
295    /// Create an empty marker content slot.
296    pub fn new() -> Self {
297        Self {
298            style: StyleRefinement::default(),
299            shimmer: false,
300            shimmer_style: ShimmerStyle::default(),
301            separator: false,
302            children: Vec::new(),
303        }
304    }
305
306    /// Add text that can receive a continuous loading shimmer.
307    ///
308    /// Arbitrary children remain supported through [`ParentElement`].
309    pub fn text(mut self, text: impl Into<SharedString>) -> Self {
310        self.children.push(MarkerContentChild::Text(text.into()));
311        self
312    }
313}
314
315impl Default for MarkerContent {
316    fn default() -> Self {
317        Self::new()
318    }
319}
320
321impl ParentElement for MarkerContent {
322    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
323        self.children
324            .extend(elements.into_iter().map(MarkerContentChild::Element));
325    }
326}
327
328impl Styled for MarkerContent {
329    fn style(&mut self) -> &mut StyleRefinement {
330        &mut self.style
331    }
332}
333
334impl RenderOnce for MarkerContent {
335    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
336        let animate = self.shimmer && !cx.reduce_motion();
337        let has_text = self
338            .children
339            .iter()
340            .any(|child| matches!(child, MarkerContentChild::Text(_)));
341        let base_opacity = self.style.opacity.unwrap_or(1.);
342        let shimmer_style = self.shimmer_style;
343        let children =
344            self.children
345                .into_iter()
346                .enumerate()
347                .map(move |(index, child)| match child {
348                    MarkerContentChild::Text(text) if animate => ShimmerText::new(text)
349                        .id(("marker-loading-text", index))
350                        .with_shimmer_style(shimmer_style)
351                        .into_any_element(),
352                    MarkerContentChild::Text(text) => StyledText::new(text).into_any_element(),
353                    MarkerContentChild::Element(element) => element,
354                });
355
356        let content = div()
357            .min_w_0()
358            .when(self.separator, |this| this.flex_none().text_center())
359            .refine_style(&self.style)
360            .children(children);
361
362        if animate && !has_text {
363            content
364                .with_animation(
365                    "marker-loading-content",
366                    shimmer_style.animation(),
367                    move |this, phase| {
368                        let highlight = (phase * std::f32::consts::TAU).cos().mul_add(0.5, 0.5);
369                        this.opacity(base_opacity * highlight.mul_add(0.4, 0.6))
370                    },
371                )
372                .into_any_element()
373        } else {
374            content.into_any_element()
375        }
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn test_marker_builder() {
385        let marker = Marker::new()
386            .with_variant(MarkerVariant::Separator)
387            .loading(true)
388            .with_loading_style(MarkerLoadingStyle::Shimmer)
389            .with_shimmer_style(ShimmerStyle::new().reverse(true))
390            .separator_style(StyleRefinement::default())
391            .content(MarkerContent::new().child("Today"));
392
393        assert_eq!(marker.variant, MarkerVariant::Separator);
394        assert!(marker.loading);
395        assert_eq!(marker.loading_style, MarkerLoadingStyle::Shimmer);
396        assert_eq!(marker.children.len(), 1);
397        assert_eq!(Marker::default().variant, MarkerVariant::Plain);
398        assert!(!Marker::default().loading);
399        assert_eq!(Marker::default().loading_style, MarkerLoadingStyle::Spinner);
400
401        let content_first = Marker::new()
402            .content(MarkerContent::new().text("Thinking"))
403            .with_loading_style(MarkerLoadingStyle::Shimmer)
404            .loading(true);
405        assert!(content_first.loading);
406        assert_eq!(content_first.loading_style, MarkerLoadingStyle::Shimmer);
407        assert!(matches!(
408            &content_first.children[0],
409            MarkerChild::Content(_)
410        ));
411
412        let custom_icon = Marker::new()
413            .loading(true)
414            .icon(MarkerIcon::new().child("custom"))
415            .content(MarkerContent::new().text("Loading"));
416        assert_eq!(custom_icon.children.len(), 2);
417        assert!(matches!(&custom_icon.children[0], MarkerChild::Icon(_)));
418
419        assert_eq!(Marker::default().role, RoleOverride::default());
420        assert!(Marker::default().id.is_none());
421        let status = Marker::new().id("sync-status").role(gpui::Role::Status);
422        assert_eq!(status.id, Some("sync-status".into()));
423        assert_eq!(status.role, RoleOverride::Role(gpui::Role::Status));
424
425        let styled = Marker::new().opacity(0.37).child("Status").child("Details");
426
427        assert_eq!(styled.style.opacity, Some(0.37));
428        assert_eq!(styled.children.len(), 2);
429
430        let icon = MarkerIcon::new().child("icon");
431        assert_eq!(icon.children.len(), 1);
432
433        let content = MarkerContent::new()
434            .text("Thinking")
435            .child("…")
436            .text("正在思考");
437        assert_eq!(content.children.len(), 3);
438        assert!(matches!(&content.children[0], MarkerContentChild::Text(_)));
439        assert!(matches!(
440            &content.children[1],
441            MarkerContentChild::Element(_)
442        ));
443        assert!(matches!(&content.children[2], MarkerContentChild::Text(_)));
444    }
445}