gpui_component/
description_list.rs

1use gpui::{
2    div, prelude::FluentBuilder as _, px, AnyElement, App, Axis, DefiniteLength, IntoElement,
3    ParentElement, RenderOnce, SharedString, Styled, Window,
4};
5
6use crate::{h_flex, text::Text, v_flex, ActiveTheme as _, AxisExt, Sizable, Size};
7
8/// A description list.
9#[derive(IntoElement)]
10pub struct DescriptionList {
11    items: Vec<DescriptionItem>,
12    size: Size,
13    layout: Axis,
14    label_width: DefiniteLength,
15    bordered: bool,
16    columns: usize,
17}
18
19/// Item for the [`DescriptionList`].
20pub enum DescriptionItem {
21    Item {
22        label: DescriptionText,
23        value: DescriptionText,
24        span: usize,
25    },
26    Divider,
27}
28
29/// Text for the label or value in the [`DescriptionList`].
30#[derive(IntoElement)]
31pub enum DescriptionText {
32    String(SharedString),
33    Text(Text),
34    AnyElement(AnyElement),
35}
36
37impl From<&str> for DescriptionText {
38    fn from(text: &str) -> Self {
39        DescriptionText::String(SharedString::from(text.to_string()))
40    }
41}
42
43impl From<Text> for DescriptionText {
44    fn from(text: Text) -> Self {
45        DescriptionText::Text(text)
46    }
47}
48
49impl From<AnyElement> for DescriptionText {
50    fn from(element: AnyElement) -> Self {
51        DescriptionText::AnyElement(element)
52    }
53}
54
55impl From<SharedString> for DescriptionText {
56    fn from(text: SharedString) -> Self {
57        DescriptionText::String(text)
58    }
59}
60
61impl From<String> for DescriptionText {
62    fn from(text: String) -> Self {
63        DescriptionText::String(SharedString::from(text))
64    }
65}
66
67impl RenderOnce for DescriptionText {
68    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
69        match self {
70            DescriptionText::String(text) => div().child(text).into_any_element(),
71            DescriptionText::Text(text) => text.into_any_element(),
72            DescriptionText::AnyElement(element) => element,
73        }
74    }
75}
76
77impl DescriptionItem {
78    /// Create a new description item, with a label.
79    ///
80    /// The value is an empty element.
81    pub fn new(label: impl Into<DescriptionText>) -> Self {
82        DescriptionItem::Item {
83            label: label.into(),
84            value: "".into(),
85            span: 1,
86        }
87    }
88
89    /// Set the element value of the item.
90    pub fn value(mut self, value: impl Into<DescriptionText>) -> Self {
91        let new_value = value.into();
92        if let DescriptionItem::Item { value, .. } = &mut self {
93            *value = new_value;
94        }
95        self
96    }
97
98    /// Set the span of the item.
99    ///
100    /// This method only works for [`DescriptionItem::Item`].
101    pub fn span(mut self, span: usize) -> Self {
102        let val = span;
103        if let DescriptionItem::Item { span, .. } = &mut self {
104            *span = val;
105        }
106        self
107    }
108
109    fn _label(&self) -> Option<&DescriptionText> {
110        match self {
111            DescriptionItem::Item { label, .. } => Some(label),
112            _ => None,
113        }
114    }
115
116    fn _span(&self) -> Option<usize> {
117        match self {
118            DescriptionItem::Item { span, .. } => Some(*span),
119            _ => None,
120        }
121    }
122}
123
124impl DescriptionList {
125    /// Create a new description list with the default layout (Horizontal).
126    pub fn new() -> Self {
127        Self {
128            items: Vec::new(),
129            layout: Axis::Horizontal,
130            label_width: px(120.).into(),
131            size: Size::default(),
132            bordered: true,
133            columns: 3,
134        }
135    }
136
137    /// Create a vertical description list.
138    pub fn vertical() -> Self {
139        Self::new().layout(Axis::Vertical)
140    }
141
142    /// Create a horizontal description list, the default.
143    pub fn horizontal() -> Self {
144        Self::new().layout(Axis::Horizontal)
145    }
146
147    /// Set the width of the label, only works for horizontal layout.
148    ///
149    /// Default is `120px`.
150    pub fn label_width(mut self, label_width: impl Into<DefiniteLength>) -> Self {
151        self.label_width = label_width.into();
152        self
153    }
154
155    /// Set the layout of the description list.
156    pub fn layout(mut self, layout: Axis) -> Self {
157        self.layout = layout;
158        self
159    }
160
161    /// Set the border of the description list, default is `true`.
162    ///
163    /// `Horizontal` layout only.
164    pub fn bordered(mut self, bordered: bool) -> Self {
165        self.bordered = bordered;
166        self
167    }
168
169    /// Set the number of columns in the description list, default is `3`.
170    ///
171    /// A value between `1` and `10` is allowed.
172    pub fn columns(mut self, columns: usize) -> Self {
173        self.columns = columns.clamp(1, 10);
174        self
175    }
176
177    /// Add a [`DescriptionItem::Item`] to the list.
178    pub fn item(
179        mut self,
180        label: impl Into<DescriptionText>,
181        value: impl Into<DescriptionText>,
182        span: usize,
183    ) -> Self {
184        self.items.push(DescriptionItem::Item {
185            label: label.into(),
186            value: value.into(),
187            span,
188        });
189        self
190    }
191
192    /// Add a child to the list.
193    pub fn child(mut self, child: impl Into<DescriptionItem>) -> Self {
194        self.items.push(child.into());
195        self
196    }
197
198    /// Add children to the list.
199    pub fn children(
200        mut self,
201        children: impl IntoIterator<Item = impl Into<DescriptionItem>>,
202    ) -> Self {
203        self.items
204            .extend(children.into_iter().map(Into::into).collect::<Vec<_>>());
205        self
206    }
207
208    /// Add a divider to the list.
209    pub fn divider(mut self) -> Self {
210        self.items.push(DescriptionItem::Divider);
211        self
212    }
213
214    fn group_item_rows(items: Vec<DescriptionItem>, columns: usize) -> Vec<Vec<DescriptionItem>> {
215        let mut rows = vec![];
216        let mut current_span = 0;
217        for item in items.into_iter() {
218            let span = item._span().unwrap_or(columns);
219            if rows.is_empty() {
220                rows.push(vec![]);
221            }
222            if current_span + span > columns {
223                rows.push(vec![]);
224                current_span = 0;
225            }
226            let last_group = rows.last_mut().unwrap();
227            last_group.push(item);
228            current_span += span;
229        }
230        // Remove last empty rows if it exists
231        while let Some(last_group) = rows.last() {
232            if !last_group.is_empty() {
233                break;
234            }
235
236            rows.pop();
237        }
238
239        rows
240    }
241}
242
243impl Sizable for DescriptionList {
244    fn with_size(mut self, size: impl Into<Size>) -> Self {
245        self.size = size.into();
246        self
247    }
248}
249
250impl RenderOnce for DescriptionList {
251    fn render(self, _: &mut Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
252        let base_gap = match self.size {
253            Size::XSmall | Size::Small => px(2.),
254            Size::Medium => px(4.),
255            Size::Large => px(8.),
256            _ => px(4.),
257        };
258
259        // Only for Horizontal layout
260        let (mut padding_x, mut padding_y) = match self.size {
261            Size::XSmall | Size::Small => (px(4.), px(2.)),
262            Size::Medium => (px(8.), px(4.)),
263            Size::Large => (px(12.), px(6.)),
264            _ => (px(8.), px(4.)),
265        };
266
267        let label_width = if self.layout.is_horizontal() {
268            Some(self.label_width)
269        } else {
270            None
271        };
272        if !self.bordered {
273            padding_x = px(0.);
274            padding_y = px(0.);
275        }
276        let gap = if self.bordered { px(0.) } else { base_gap };
277
278        // Group items by columns
279        let rows = Self::group_item_rows(self.items, self.columns);
280        let rows_len = rows.len();
281
282        v_flex()
283            .gap(gap)
284            .overflow_hidden()
285            .when(self.bordered, |this| {
286                this.rounded(padding_x)
287                    .border_1()
288                    .border_color(cx.theme().border)
289            })
290            .children(rows.into_iter().enumerate().map(|(ix, items)| {
291                let is_last = ix == rows_len - 1;
292                h_flex()
293                    .when(self.bordered && !is_last, |this| {
294                        this.border_b_1().border_color(cx.theme().border)
295                    })
296                    .children({
297                        items.into_iter().enumerate().map(|(item_ix, item)| {
298                            let is_first_col = item_ix == 0;
299
300                            match item {
301                                DescriptionItem::Item { label, value, .. } => {
302                                    let el = if self.layout.is_vertical() {
303                                        v_flex()
304                                    } else {
305                                        div().flex().flex_row().h_full()
306                                    };
307
308                                    el.flex_1()
309                                        .overflow_x_hidden()
310                                        .child(
311                                            div()
312                                                .when(self.layout.is_horizontal(), |this| {
313                                                    this.h_full()
314                                                })
315                                                .text_color(
316                                                    cx.theme().description_list_label_foreground,
317                                                )
318                                                .text_sm()
319                                                .px(padding_x)
320                                                .py(padding_y)
321                                                .when(self.bordered, |this| {
322                                                    this.when(self.layout.is_horizontal(), |this| {
323                                                        this.border_r_1()
324                                                            .when(!is_first_col, |this| {
325                                                                this.border_l_1()
326                                                            })
327                                                    })
328                                                    .when(self.layout.is_vertical(), |this| {
329                                                        this.border_b_1()
330                                                    })
331                                                    .border_color(cx.theme().border)
332                                                    .bg(cx.theme().description_list_label)
333                                                })
334                                                .map(|this| match label_width {
335                                                    Some(label_width) => {
336                                                        this.w(label_width).flex_shrink_0()
337                                                    }
338                                                    None => this,
339                                                })
340                                                .child(label),
341                                        )
342                                        .child(
343                                            div()
344                                                .flex_1()
345                                                .px(padding_x)
346                                                .py(padding_y)
347                                                .overflow_hidden()
348                                                .child(value),
349                                        )
350                                }
351                                _ => div().h_2().w_full().when(self.bordered, |this| {
352                                    this.bg(cx.theme().description_list_label)
353                                }),
354                            }
355                        })
356                    })
357            }))
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::DescriptionItem;
364
365    #[test]
366    fn test_group_item_rows() {
367        let items = vec![
368            DescriptionItem::new("test1"),
369            DescriptionItem::new("test2").span(2),
370            DescriptionItem::new("test3"),
371            DescriptionItem::new("test4"),
372            DescriptionItem::new("test5"),
373            DescriptionItem::new("test6").span(3),
374            DescriptionItem::new("test7"),
375        ];
376        let rows = super::DescriptionList::group_item_rows(items, 3);
377        assert_eq!(rows.len(), 4);
378        assert_eq!(rows[0].len(), 2);
379        assert_eq!(rows[1].len(), 3);
380        assert_eq!(rows[2].len(), 1);
381        assert_eq!(rows[3].len(), 1);
382    }
383}