Skip to main content

gpui_cn/composer/
status.rs

1use gpui_kit::{
2    AnyElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce,
3    StyleRefinement, Styled, TestSupportExt as _, Window, base::StyledExt as _, div,
4    prelude::FluentBuilder as _, px,
5};
6
7use crate::{
8    ActiveTheme as _, Progress, StatusSelect,
9    collapse::{self, Fit, Measured},
10};
11
12/// The context a conversation has used, as a ring and a percent: a
13/// circular [`Progress`] beside the number, in the muted text color.
14///
15/// The percent is a plain value the application sets.
16///
17/// ```
18/// use gpui_cn::ContextMeter;
19///
20/// let _ = ContextMeter::new("context").percent(57.);
21/// ```
22#[derive(IntoElement)]
23#[non_exhaustive]
24pub struct ContextMeter {
25    id: ElementId,
26    percent: f32,
27    style: StyleRefinement,
28}
29
30impl ContextMeter {
31    /// An empty meter with a stable id.
32    pub fn new(id: impl Into<ElementId>) -> Self {
33        Self {
34            id: id.into(),
35            percent: 0.,
36            style: StyleRefinement::default(),
37        }
38    }
39
40    /// The percentage used, clamped to `0..=100`.
41    pub fn percent(mut self, percent: f32) -> Self {
42        self.percent = percent.clamp(0., 100.);
43        self
44    }
45
46    /// The percentage used.
47    pub fn percent_used(&self) -> f32 {
48        self.percent
49    }
50}
51
52impl Styled for ContextMeter {
53    fn style(&mut self) -> &mut StyleRefinement {
54        &mut self.style
55    }
56}
57
58impl RenderOnce for ContextMeter {
59    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
60        let theme = cx.theme();
61        let (muted, gap) = (theme.muted_foreground(), theme.metrics.status_label_gap);
62        let percent = self.percent;
63        div()
64            .id(self.id.clone())
65            .test_support()
66            .flex()
67            .flex_shrink_0()
68            .items_center()
69            .gap(gap)
70            .text_color(muted)
71            .refine_style(&self.style)
72            .child(
73                Progress::new(ElementId::NamedChild(self.id.into(), "ring".into()))
74                    .circular()
75                    .value(percent)
76                    .accessibility_label("Context used"),
77            )
78            .child(format!("{percent:.0}%"))
79    }
80}
81
82/// The strip behind the top of a composer's card: any number of items
83/// from the left, such as a folder, a device, and a branch, in the full
84/// text color (each a [`StatusSelect`] that opens a menu), and optionally a [`ContextMeter`] or any element at the far
85/// right. With no trailing element there is nothing at the right.
86///
87/// The tab is inset from each side of the card and rounded on top only.
88/// The tab is exactly [`status_tab_height`](crate::theme::MetricTokens::status_tab_height)
89/// tall, with the items centered in it and square bottom corners, and it
90/// carries no hidden height. A [`Composer`](crate::Composer) sets its card
91/// directly under the tab, tucked over the tab's bottom edge by the card's
92/// hairline.
93/// [`select`](Self::select) adds a dropdown item, any other element joins
94/// the left side as a child, [`context`](Self::context) adds the meter,
95/// and [`trailing`](Self::trailing) replaces what is at the right.
96///
97/// ```
98/// use gpui_cn::ComposerStatusTab;
99///
100/// let _ = ComposerStatusTab::new("status").context(57.);
101/// ```
102#[derive(IntoElement)]
103#[non_exhaustive]
104pub struct ComposerStatusTab {
105    id: ElementId,
106    items: Vec<Entry>,
107    trailing: Option<AnyElement>,
108    style: StyleRefinement,
109}
110
111/// An item of the tab: a select the tab can collapse to an icon, or any
112/// other element.
113enum Entry {
114    Select(Box<StatusSelect>),
115    Other(AnyElement),
116}
117
118impl ComposerStatusTab {
119    /// An empty tab with a stable id.
120    pub fn new(id: impl Into<ElementId>) -> Self {
121        Self {
122            id: id.into(),
123            items: Vec::new(),
124            trailing: None,
125            style: StyleRefinement::default(),
126        }
127    }
128
129    /// An item that opens a menu of options. See [`StatusSelect`].
130    pub fn select(mut self, select: StatusSelect) -> Self {
131        self.items.push(Entry::Select(Box::new(select)));
132        self
133    }
134
135    /// The context meter at the far right, at `percent` used.
136    pub fn context(mut self, percent: f32) -> Self {
137        let id = ElementId::NamedChild(self.id.clone().into(), "context".into());
138        self.trailing = Some(ContextMeter::new(id).percent(percent).into_any_element());
139        self
140    }
141
142    /// Replaces what sits at the far right.
143    pub fn trailing(mut self, element: impl IntoElement) -> Self {
144        self.trailing = Some(element.into_any_element());
145        self
146    }
147}
148
149impl Styled for ComposerStatusTab {
150    fn style(&mut self) -> &mut StyleRefinement {
151        &mut self.style
152    }
153}
154
155/// Children that are not a [`StatusSelect`](crate::StatusSelect) keep the width
156/// they have: they take no part in the plan that shrinks labels and turns
157/// selects into icons when the tab is narrow.
158impl ParentElement for ComposerStatusTab {
159    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
160        self.items.extend(elements.into_iter().map(Entry::Other));
161    }
162}
163
164impl RenderOnce for ComposerStatusTab {
165    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
166        let theme = cx.theme();
167        let metrics = &theme.metrics;
168        let (height, inset, gap) = (
169            metrics.status_tab_height,
170            metrics.status_tab_inset,
171            metrics.status_tab_gap,
172        );
173        let (pad_left, pad_right) = (metrics.status_tab_inset, theme.base.spacing.lg);
174        let tight = theme.base.spacing.sm;
175        let (fill, radius, text) = (theme.sidebar, theme.radius_lg(), theme.text_control);
176        let widths = window
177            .use_keyed_state(
178                ElementId::NamedChild(self.id.clone().into(), "widths".into()),
179                cx,
180                |_, _| (Measured::default(), Measured::default()),
181            )
182            .read(cx)
183            .clone();
184        let (tab_width, trailing_width) = widths;
185        let needs: Vec<_> = self
186            .items
187            .iter()
188            .filter_map(|entry| match entry {
189                Entry::Select(select) => Some(select.need(window, cx)),
190                Entry::Other(_) => None,
191            })
192            .collect();
193        let order: Vec<usize> = (0..needs.len()).rev().collect();
194        let trailing_gap = if self.trailing.is_some() { gap } else { px(0.) };
195        let available =
196            tab_width.width() - pad_left - pad_right - trailing_width.width() - trailing_gap;
197        let collapsed = collapse::plan(
198            if tab_width.width() > px(0.) {
199                available
200            } else {
201                px(0.)
202            },
203            gap,
204            tight,
205            &needs,
206            &order,
207        );
208        let gap = if collapsed.iter().any(|fit| fit.is_icon()) {
209            tight
210        } else {
211            gap
212        };
213        let mut selects = 0;
214        let items: Vec<AnyElement> = self
215            .items
216            .into_iter()
217            .map(|entry| match entry {
218                Entry::Select(select) => {
219                    let fit = collapsed.get(selects).copied().unwrap_or(Fit::Natural);
220                    selects += 1;
221                    match fit {
222                        Fit::Icon => select.icon_only(true),
223                        Fit::Shrunk(width) => select.width(width),
224                        Fit::Natural => *select,
225                    }
226                    .into_any_element()
227                }
228                Entry::Other(element) => element,
229            })
230            .collect();
231        div()
232            .id(self.id)
233            .test_support()
234            .relative()
235            .flex()
236            .items_center()
237            .justify_between()
238            .h(height)
239            .mx(inset)
240            .pl(pad_left)
241            .pr(pad_right)
242            .gap(gap)
243            .rounded_t(radius)
244            .bg(fill)
245            .text_size(text.size)
246            .line_height(text.line_height)
247            .refine_style(&self.style)
248            .child(tab_width.probe())
249            .child(
250                div()
251                    .flex()
252                    .flex_1()
253                    .min_w_0()
254                    .items_center()
255                    .gap(gap)
256                    .children(items),
257            )
258            .when_some(self.trailing, |this, trailing| {
259                this.child(
260                    div()
261                        .relative()
262                        .flex_shrink_0()
263                        .child(trailing)
264                        .child(trailing_width.probe()),
265                )
266            })
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn the_percent_clamps() {
276        assert_eq!(ContextMeter::new("m").percent(57.).percent_used(), 57.);
277        assert_eq!(ContextMeter::new("m").percent(180.).percent_used(), 100.);
278        assert_eq!(ContextMeter::new("m").percent(-1.).percent_used(), 0.);
279    }
280}