Skip to main content

gpui_kit/display/
description_list.rs

1//! Key and value pairs for a detail page.
2//!
3//! An empty string is not a fact. A value nobody knows, a value that does not
4//! apply here, and a value that exists but may not be shown are three
5//! different sentences, so they are three different values — and a redacted
6//! one publishes its shape and never its text.
7
8use std::rc::Rc;
9
10use gpui::{
11    App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
12    StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, relative,
13};
14use gpui_kit_assets::{Icon, icon};
15use gpui_kit_semantics::{NodeSpec, Role, Semantic};
16use gpui_kit_theme::{ActiveTheme, Radius, Space, TypeScale};
17use unicode_segmentation::UnicodeSegmentation;
18
19use crate::foundation::{FocusRing, Ident, Pressable, StyledExt};
20use crate::strings::{ActiveStrings, StringKey};
21
22type CopyHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
23
24/// What a detail row holds.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum DescriptionValue {
27    Text(SharedString),
28    /// Nobody knows what this is. Not the same as empty.
29    Unknown,
30    /// The question does not arise for this record.
31    NotApplicable,
32    /// The value exists and may not be shown. Only its shape is carried, so
33    /// there is no text here for a snapshot or an export to leak.
34    Redacted(SharedString),
35}
36
37impl DescriptionValue {
38    pub fn text(value: impl Into<SharedString>) -> Self {
39        Self::Text(value.into())
40    }
41
42    /// A redacted value described by its shape, such as `"51 characters"`.
43    ///
44    /// The shape is all the component ever sees. Callers that hold the secret
45    /// itself want [`DescriptionValue::redacted_from`], which measures the
46    /// text and drops it.
47    pub fn redacted(shape: impl Into<SharedString>) -> Self {
48        Self::Redacted(shape.into())
49    }
50
51    /// Measures a secret and keeps only the measurement.
52    ///
53    /// The measurement is a sentence a reader reads, so it comes from the
54    /// installed catalogue rather than from this file.
55    pub fn redacted_from(secret: &str, cx: &App) -> Self {
56        Self::Redacted(cx.strings().format(
57            StringKey::DescriptionCharacters,
58            &[&secret.graphemes(true).count().to_string()],
59        ))
60    }
61
62    /// The name a semantic node publishes for the kind of value this is.
63    pub fn as_str(&self) -> &'static str {
64        match self {
65            Self::Text(_) => "text",
66            Self::Unknown => "unknown",
67            Self::NotApplicable => "not-applicable",
68            Self::Redacted(_) => "redacted",
69        }
70    }
71
72    /// What the node publishes as its value: the text when there is text to
73    /// show, and the shape or the state when there is not.
74    fn published(&self) -> SharedString {
75        match self {
76            Self::Text(value) => value.clone(),
77            Self::Unknown => SharedString::new_static("unknown"),
78            Self::NotApplicable => SharedString::new_static("not applicable"),
79            Self::Redacted(shape) => SharedString::from(format!("redacted, {shape}")),
80        }
81    }
82
83    /// Whether there is anything for the host to put on the clipboard.
84    fn is_copyable(&self) -> bool {
85        matches!(self, Self::Text(_) | Self::Redacted(_))
86    }
87}
88
89impl From<SharedString> for DescriptionValue {
90    fn from(value: SharedString) -> Self {
91        Self::Text(value)
92    }
93}
94
95impl From<&'static str> for DescriptionValue {
96    fn from(value: &'static str) -> Self {
97        Self::Text(SharedString::new_static(value))
98    }
99}
100
101impl From<String> for DescriptionValue {
102    fn from(value: String) -> Self {
103        Self::Text(SharedString::from(value))
104    }
105}
106
107/// One term and what it describes.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct DescriptionItem {
110    id: SharedString,
111    term: SharedString,
112    value: DescriptionValue,
113    copyable: bool,
114}
115
116impl DescriptionItem {
117    pub fn new(
118        id: impl Into<SharedString>,
119        term: impl Into<SharedString>,
120        value: impl Into<DescriptionValue>,
121    ) -> Self {
122        Self {
123            id: id.into(),
124            term: term.into(),
125            value: value.into(),
126            copyable: false,
127        }
128    }
129
130    /// Offers a copy action, which reports the item and copies nothing itself.
131    pub fn copyable(mut self, copyable: bool) -> Self {
132        self.copyable = copyable;
133        self
134    }
135
136    pub fn id(&self) -> &SharedString {
137        &self.id
138    }
139
140    pub fn value(&self) -> &DescriptionValue {
141        &self.value
142    }
143}
144
145/// A list of term and description pairs, in one or two columns.
146#[derive(IntoElement)]
147pub struct DescriptionList {
148    ident: Ident,
149    items: Vec<DescriptionItem>,
150    columns: usize,
151    on_copy: Option<CopyHandler>,
152}
153
154impl std::fmt::Debug for DescriptionList {
155    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        formatter
157            .debug_struct("DescriptionList")
158            .field("ident", &self.ident)
159            .field("items", &self.items.len())
160            .field("columns", &self.columns)
161            .finish()
162    }
163}
164
165impl DescriptionList {
166    pub fn new(ident: impl Into<Ident>) -> Self {
167        Self {
168            ident: ident.into(),
169            items: Vec::new(),
170            columns: 1,
171            on_copy: None,
172        }
173    }
174
175    pub fn item(mut self, item: DescriptionItem) -> Self {
176        self.items.push(item);
177        self
178    }
179
180    pub fn items(mut self, items: impl IntoIterator<Item = DescriptionItem>) -> Self {
181        self.items.extend(items);
182        self
183    }
184
185    /// One column or two. Anything else is one, because a detail page that
186    /// needs three columns needs a table.
187    pub fn columns(mut self, columns: usize) -> Self {
188        self.columns = if columns >= 2 { 2 } else { 1 };
189        self
190    }
191
192    /// Reports the item whose value the typist asked for. The list holds no
193    /// clipboard of its own, and a redacted value's text never reaches it.
194    pub fn on_copy(
195        mut self,
196        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
197    ) -> Self {
198        self.on_copy = Some(Rc::new(handler));
199        self
200    }
201}
202
203impl RenderOnce for DescriptionList {
204    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
205        let theme = cx.theme().clone();
206        let columns = self.columns;
207        let count = self.items.len();
208
209        let rows = self.items.into_iter().map(|item| {
210            let ident = self.ident.child(item.id.as_ref());
211            let copyable = item.copyable && item.value.is_copyable();
212            let value = match &item.value {
213                DescriptionValue::Text(text) => div()
214                    .type_scale(&theme, TypeScale::Label)
215                    .text_color(theme.colors.text)
216                    .child(text.clone()),
217                DescriptionValue::Unknown => div()
218                    .type_scale(&theme, TypeScale::Label)
219                    .text_color(theme.colors.text_faint)
220                    .child(cx.strings().text(StringKey::DescriptionUnknown)),
221                DescriptionValue::NotApplicable => div()
222                    .type_scale(&theme, TypeScale::Label)
223                    .text_color(theme.colors.text_faint)
224                    .child(cx.strings().text(StringKey::DescriptionNotApplicable)),
225                // The dots are the value: the text is not here to draw, and a
226                // masked rendering of the real thing would still be the real
227                // thing one screenshot away.
228                DescriptionValue::Redacted(shape) => div()
229                    .row()
230                    .gap_token(&theme, Space::Sm)
231                    .type_scale(&theme, TypeScale::Label)
232                    .text_color(theme.colors.text_muted)
233                    .child(SharedString::new_static("••••••••"))
234                    .child(
235                        div()
236                            .type_scale(&theme, TypeScale::Caption)
237                            .text_color(theme.colors.text_faint)
238                            .child(shape.clone()),
239                    ),
240            };
241
242            let copy = self.on_copy.clone().filter(|_| copyable).map(|handler| {
243                let copy_ident = ident.child("copy");
244                let id = item.id.clone();
245                let name = cx
246                    .strings()
247                    .format(StringKey::DescriptionCopy, &[&item.term]);
248                div()
249                    .id(copy_ident.element_id())
250                    .flex_none()
251                    .flex()
252                    .items_center()
253                    .justify_center()
254                    .size(px(theme.control.xs.height))
255                    .radius(&theme, Radius::Small)
256                    .cursor_pointer()
257                    .tab_index(0)
258                    .text_color(theme.colors.text_faint)
259                    .hover(|style| style.bg(theme.colors.hover))
260                    .pressable(cx)
261                    .focus_ring(&theme)
262                    .child(icon(Icon::Copy).size(px(theme.control.xs.icon_size)))
263                    .on_click(move |_, window, cx| handler(id.clone(), window, cx))
264                    .semantic_in(
265                        cx,
266                        NodeSpec::new(copy_ident.semantic_id(), Role::Button)
267                            .parent(ident.semantic_id())
268                            .text(name),
269                    )
270            });
271
272            div()
273                .row()
274                .items_start()
275                .gap_token(&theme, Space::Md)
276                .py_token(&theme, Space::Xs)
277                .when(columns == 2, |element| element.w(relative(0.5)).flex_none())
278                .when(columns == 1, |element| element.w_full())
279                .child(
280                    div()
281                        .w(px(140.0))
282                        .flex_none()
283                        .type_scale(&theme, TypeScale::Caption)
284                        .text_color(theme.colors.text_muted)
285                        .child(item.term.clone()),
286                )
287                .child(div().flex_1().min_w_0().child(value))
288                .children(copy)
289                .semantic_in(
290                    cx,
291                    NodeSpec::new(ident.semantic_id(), Role::Row)
292                        .parent(self.ident.semantic_id())
293                        .text(item.term.clone())
294                        .value(item.value.published()),
295                )
296        });
297
298        div()
299            .w_full()
300            .flex()
301            .flex_row()
302            .flex_wrap()
303            .children(rows)
304            .semantic_in(
305                cx,
306                NodeSpec::new(self.ident.semantic_id(), Role::List).value(count.to_string()),
307            )
308    }
309}