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