Skip to main content

gpui_kit/controls/
filter_bar.rs

1//! The conditions a list is currently filtered by, as chips that can be taken
2//! off again.
3//!
4//! The conditions belong to the caller and so does the count. The bar renders
5//! exactly what it was handed and reports every gesture, so a host that
6//! refuses to drop a condition keeps showing it.
7
8use std::rc::Rc;
9
10use gpui::{
11    AnyElement, App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
12    prelude::FluentBuilder,
13};
14use gpui_kit_semantics::{NodeSpec, Role, Semantic};
15use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, TypeScale};
16
17use crate::controls::button::Button;
18use crate::display::badge::Tone;
19use crate::display::tag::Tag;
20use crate::foundation::{Disableable, Ident, Sizable, StyledExt, text as foundation_text};
21use crate::strings::{ActiveStrings, StringKey};
22
23type RemoveHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
24type ClearHandler = Rc<dyn Fn(&mut Window, &mut App)>;
25type AddHandler = Rc<dyn Fn(&mut Window, &mut App)>;
26
27/// One condition the list is narrowed by.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct FilterCondition {
30    id: SharedString,
31    field: SharedString,
32    operator: SharedString,
33    value: SharedString,
34    tone: Tone,
35}
36
37impl FilterCondition {
38    pub fn new(
39        id: impl Into<SharedString>,
40        field: impl Into<SharedString>,
41        operator: impl Into<SharedString>,
42        value: impl Into<SharedString>,
43    ) -> Self {
44        Self {
45            id: id.into(),
46            field: field.into(),
47            operator: operator.into(),
48            value: value.into(),
49            tone: Tone::Accent,
50        }
51    }
52
53    pub fn tone(mut self, tone: Tone) -> Self {
54        self.tone = tone;
55        self
56    }
57
58    pub fn id(&self) -> &SharedString {
59        &self.id
60    }
61
62    /// How the condition reads as one line: field, operator, value.
63    pub fn label(&self) -> SharedString {
64        SharedString::from(format!("{} {} {}", self.field, self.operator, self.value))
65    }
66}
67
68/// How many rows the host says the filter matched.
69///
70/// Counting is not zero. A bar that renders an unfinished count as "0 results"
71/// tells the typist their filter matched nothing, which nobody has established
72/// yet.
73#[derive(Debug, Clone, PartialEq, Eq, Default)]
74pub enum ResultCount {
75    /// Nothing has been counted, and nothing is claimed.
76    #[default]
77    Unknown,
78    /// The host is counting right now.
79    Counting,
80    /// The host counted, and this is the number.
81    Known(usize),
82    /// The host could not count, in its own words.
83    Unavailable(SharedString),
84}
85
86impl ResultCount {
87    pub fn as_str(&self) -> &'static str {
88        match self {
89            Self::Unknown => "unknown",
90            Self::Counting => "counting",
91            Self::Known(_) => "known",
92            Self::Unavailable(_) => "unavailable",
93        }
94    }
95
96    /// The words the bar shows, and the value it publishes.
97    fn sentence(&self, noun: &SharedString, cx: &App) -> Option<SharedString> {
98        match self {
99            Self::Unknown => None,
100            Self::Counting => Some(cx.strings().text(StringKey::FilterBarCounting)),
101            Self::Known(count) => Some(SharedString::from(format!("{count} {noun}"))),
102            Self::Unavailable(reason) => Some(reason.clone()),
103        }
104    }
105}
106
107/// A row of active conditions, a way to add one, a clear-all, and the count.
108#[derive(IntoElement)]
109pub struct FilterBar {
110    ident: Ident,
111    conditions: Vec<FilterCondition>,
112    count: ResultCount,
113    noun: Option<SharedString>,
114    add_control: Option<AnyElement>,
115    add_label: Option<SharedString>,
116    clear_label: Option<SharedString>,
117    size: gpui_kit_theme::ControlSize,
118    disabled: bool,
119    on_add: Option<AddHandler>,
120    on_remove: Option<RemoveHandler>,
121    on_clear: Option<ClearHandler>,
122}
123
124impl std::fmt::Debug for FilterBar {
125    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        formatter
127            .debug_struct("FilterBar")
128            .field("ident", &self.ident)
129            .field("conditions", &self.conditions.len())
130            .field("count", &self.count)
131            .field("disabled", &self.disabled)
132            .finish()
133    }
134}
135
136impl FilterBar {
137    pub fn new(ident: impl Into<Ident>) -> Self {
138        Self {
139            ident: ident.into(),
140            conditions: Vec::new(),
141            count: ResultCount::default(),
142            noun: None,
143            add_control: None,
144            add_label: None,
145            clear_label: None,
146            size: gpui_kit_theme::ControlSize::Sm,
147            disabled: false,
148            on_add: None,
149            on_remove: None,
150            on_clear: None,
151        }
152    }
153
154    pub fn condition(mut self, condition: FilterCondition) -> Self {
155        self.conditions.push(condition);
156        self
157    }
158
159    pub fn conditions(mut self, conditions: impl IntoIterator<Item = FilterCondition>) -> Self {
160        self.conditions.extend(conditions);
161        self
162    }
163
164    /// The count the host established. The bar never counts anything itself.
165    pub fn count(mut self, count: ResultCount) -> Self {
166        self.count = count;
167        self
168    }
169
170    /// What is being counted, for the sentence beside the number.
171    pub fn noun(mut self, noun: impl Into<SharedString>) -> Self {
172        self.noun = Some(noun.into());
173        self
174    }
175
176    /// The affordance that offers the fields a filter can be built on, usually
177    /// a [`crate::overlay::Popover`] or a [`crate::overlay::Menu`]. It takes
178    /// the place of the built-in button.
179    pub fn add_control(mut self, control: impl IntoElement) -> Self {
180        self.add_control = Some(control.into_any_element());
181        self
182    }
183
184    pub fn add_label(mut self, label: impl Into<SharedString>) -> Self {
185        self.add_label = Some(label.into());
186        self
187    }
188
189    pub fn clear_label(mut self, label: impl Into<SharedString>) -> Self {
190        self.clear_label = Some(label.into());
191        self
192    }
193
194    /// Reports that the typist asked to add a condition. Ignored when the
195    /// caller supplied their own affordance with [`FilterBar::add_control`].
196    pub fn on_add(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
197        self.on_add = Some(Rc::new(handler));
198        self
199    }
200
201    /// Reports the condition the typist took off. The bar removes nothing.
202    pub fn on_remove(
203        mut self,
204        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
205    ) -> Self {
206        self.on_remove = Some(Rc::new(handler));
207        self
208    }
209
210    pub fn on_clear(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
211        self.on_clear = Some(Rc::new(handler));
212        self
213    }
214}
215
216impl Disableable for FilterBar {
217    fn disabled(mut self, disabled: bool) -> Self {
218        self.disabled = disabled;
219        self
220    }
221}
222
223impl Sizable for FilterBar {
224    fn control_size(mut self, size: gpui_kit_theme::ControlSize) -> Self {
225        self.size = size;
226        self
227    }
228}
229
230impl RenderOnce for FilterBar {
231    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
232        let theme = cx.theme().clone();
233        let active = self.conditions.len();
234        let removable = self.on_remove.clone().filter(|_| !self.disabled);
235
236        let chips = self.conditions.iter().map(|condition| {
237            let ident = self.ident.child(condition.id.as_ref());
238            let mut chip = Tag::new(ident, condition.label())
239                .tone(condition.tone)
240                .disabled(self.disabled);
241            if let Some(handler) = removable.clone() {
242                let id = condition.id.clone();
243                chip = chip.on_remove(move |window, cx| handler(id.clone(), window, cx));
244            }
245            chip
246        });
247
248        let add = match self.add_control {
249            Some(control) => Some(div().flex_none().child(control).into_any_element()),
250            None => self
251                .on_add
252                .clone()
253                .filter(|_| !self.disabled)
254                .map(|handler| {
255                    Button::new(self.ident.child("add"))
256                        .label(
257                            self.add_label
258                                .clone()
259                                .unwrap_or_else(|| cx.strings().text(StringKey::FilterBarAdd)),
260                        )
261                        .ghost()
262                        .control_size(self.size)
263                        .semantic_parent(self.ident.semantic_id())
264                        .icon(gpui_kit_assets::Icon::Plus)
265                        .on_click(move |window, cx| handler(window, cx))
266                        .into_any_element()
267                }),
268        };
269
270        let clear = self
271            .on_clear
272            .clone()
273            .filter(|_| !self.disabled && active > 0)
274            .map(|handler| {
275                Button::new(self.ident.child("clear"))
276                    .label(
277                        self.clear_label
278                            .clone()
279                            .unwrap_or_else(|| cx.strings().text(StringKey::FilterBarClear)),
280                    )
281                    .ghost()
282                    .control_size(self.size)
283                    .semantic_parent(self.ident.semantic_id())
284                    .on_click(move |window, cx| handler(window, cx))
285            });
286
287        let count_ident = self.ident.child("count");
288        let noun = self
289            .noun
290            .clone()
291            .unwrap_or_else(|| cx.strings().text(StringKey::FilterBarResultsNoun));
292        let count = self.count.sentence(&noun, cx).map(|sentence| {
293            foundation_text(&theme, TypeScale::Caption, sentence.clone())
294                .flex_none()
295                .text_color(match self.count {
296                    ResultCount::Unavailable(_) => theme.colors.warning,
297                    ResultCount::Counting => theme.colors.text_faint,
298                    _ => theme.colors.text_muted,
299                })
300                .semantic_in(
301                    cx,
302                    NodeSpec::new(count_ident.semantic_id(), Role::Status)
303                        .parent(self.ident.semantic_id())
304                        .text(sentence)
305                        // The state is published by name so a test tells a
306                        // count in progress from a count of zero.
307                        .value(self.count.as_str())
308                        .busy(self.count == ResultCount::Counting),
309                )
310        });
311
312        div()
313            .row()
314            .w_full()
315            .flex_wrap()
316            .items_center()
317            .gap_token(&theme, Space::Sm)
318            .px_token(&theme, Space::Sm)
319            .py_token(&theme, Space::Xs)
320            .radius(&theme, Radius::Card)
321            .frame(&theme, Surface::Panel, Elevation::Raised)
322            .when(self.disabled, |element| {
323                element.opacity(theme.opacity.disabled)
324            })
325            .children(chips)
326            .children(add)
327            .child(div().flex_1())
328            .children(count)
329            .children(clear)
330            .semantic_in(
331                cx,
332                NodeSpec::new(self.ident.semantic_id(), Role::Toolbar)
333                    .text(cx.strings().text(StringKey::FilterBarLabel))
334                    .disabled(self.disabled)
335                    .value(active.to_string()),
336            )
337    }
338}