Skip to main content

gpui_ai/
filter_table.rs

1//! Controlled filter-table values and animated stable-row presentation.
2
3use gpui_base::StyledExt as _;
4use std::{collections::HashSet, sync::Arc};
5
6use gpui::{
7    App, AppContext as _, Axis, Context, Div, EventEmitter, FocusHandle, Focusable,
8    InteractiveElement as _, ParentElement as _, Render, Role, ScrollHandle, SharedString,
9    Stateful, StatefulInteractiveElement as _, Styled as _, Subscription, Window, accesskit, div,
10    prelude::FluentBuilder as _,
11};
12use gpui_base::InteractiveElementExt as _;
13use gpui_component::{ActiveTheme as _, scroll::ScrollableMask};
14
15use crate::scrolling::PolicyScrollbarExt as _;
16use crate::{
17    control::outlined_control_with_label,
18    motion::MotionTokens,
19    records_table::{
20        RecordsTable, RecordsTableEvent, record_columns_have_unique_ids,
21        record_rows_have_unique_ids,
22    },
23    stream::Progressive,
24    theme::SemanticStyledExt as _,
25};
26
27pub use crate::records_table::{
28    RecordCell as FilterCell, RecordColumn as FilterColumn,
29    RecordColumnAlignment as FilterColumnAlignment, RecordRow as FilterRow,
30    RecordSortDirection as FilterSortDirection,
31};
32
33/// One consumer-owned filter choice with stable application identity.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct FilterDefinition {
36    id: SharedString,
37    label: SharedString,
38    count: usize,
39    active: bool,
40    disabled: bool,
41}
42
43impl FilterDefinition {
44    /// Creates an inactive filter with its current result count.
45    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>, count: usize) -> Self {
46        Self {
47            id: id.into(),
48            label: label.into(),
49            count,
50            active: false,
51            disabled: false,
52        }
53    }
54
55    /// Sets whether this filter is active in the consumer-owned snapshot.
56    pub fn active(mut self, active: bool) -> Self {
57        self.active = active;
58        self
59    }
60
61    /// Sets whether this filter rejects activation intent.
62    pub fn disabled(mut self, disabled: bool) -> Self {
63        self.disabled = disabled;
64        self
65    }
66
67    /// Returns the stable application filter ID.
68    pub fn id(&self) -> &str {
69        self.id.as_ref()
70    }
71
72    /// Returns the visible filter label.
73    pub fn label(&self) -> &str {
74        self.label.as_ref()
75    }
76
77    /// Returns the consumer-supplied result count.
78    pub fn count(&self) -> usize {
79        self.count
80    }
81
82    /// Returns whether this filter is active.
83    pub fn is_active(&self) -> bool {
84        self.active
85    }
86
87    /// Returns whether this filter rejects activation intent.
88    pub fn is_disabled(&self) -> bool {
89        self.disabled
90    }
91}
92
93/// Typed application intent emitted by a filter table.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum FilterTableEvent {
96    /// Requests a new controlled active state for one stable filter.
97    FilterRequested {
98        /// Stable filter-table ID.
99        id: SharedString,
100        /// Stable application filter ID.
101        filter_id: SharedString,
102        /// Requested active state.
103        active: bool,
104    },
105    /// Requests that the application select the identified row.
106    SelectionRequested {
107        /// Stable filter-table ID.
108        id: SharedString,
109        /// Stable application row ID.
110        row_id: SharedString,
111    },
112    /// Requests the primary action for the identified row.
113    ActivationRequested {
114        /// Stable filter-table ID.
115        id: SharedString,
116        /// Stable application row ID.
117        row_id: SharedString,
118    },
119    /// Requests a controlled sort projection.
120    SortRequested {
121        /// Stable filter-table ID.
122        id: SharedString,
123        /// Stable application column ID.
124        column_id: SharedString,
125        /// Requested direction, or `None` to clear sorting.
126        direction: Option<FilterSortDirection>,
127    },
128}
129
130/// A controlled, virtualized records table with stable filter controls and reorder motion.
131///
132/// Applications own filters, filtered row order, selection, sorting, and async
133/// work. When the ordered row snapshot changes, rows that remain visible move
134/// to their new positions through GPUI's finite keyed transition facility.
135/// Reduced-motion mode snaps directly to the new order.
136pub struct FilterTable {
137    /// Styles the caller put on this component, applied to its own frame.
138    ///
139    /// Last, so a caller outranks the component's defaults - the same rule the
140    /// builder components follow. A wrapper `div` cannot stand in for this:
141    /// a background, a border, or an ink set on a wrapper paints around the
142    /// component rather than on it.
143    style: gpui::StyleRefinement,
144    id: SharedString,
145    label: SharedString,
146    filters: Arc<[FilterDefinition]>,
147    columns: Arc<[FilterColumn]>,
148    rows: Progressive<Arc<[FilterRow]>>,
149    selected_row_id: Option<SharedString>,
150    sort_column_id: Option<SharedString>,
151    sort_direction: Option<FilterSortDirection>,
152    records_table: gpui::Entity<RecordsTable>,
153    filter_scroll: ScrollHandle,
154    filter_focus: FocusHandle,
155    focused_filter_id: Option<SharedString>,
156    filter_focus_engaged: bool,
157    _records_subscription: Subscription,
158    _filter_focus_subscriptions: Vec<Subscription>,
159}
160
161impl FilterTable {
162    /// Creates an empty filter table with stable identity and an accessible label.
163    pub fn new(
164        id: impl Into<SharedString>,
165        label: impl Into<SharedString>,
166        window: &mut Window,
167        cx: &mut Context<Self>,
168    ) -> Self {
169        let id = id.into();
170        let label = label.into();
171        let records_id = scoped_filter_id("records", &id, "table");
172        let records_label = label.clone();
173        let records_table = cx.new(|cx| {
174            let mut table = RecordsTable::new(records_id, records_label, window, cx);
175            // Visible-row reorder rides the policy's reflow role, read at
176            // construction: the proven response, replaceable with the
177            // policy before the table is built.
178            table.set_row_reorder_response(
179                Some(MotionTokens::read(cx).reflow_spring().response()),
180                cx,
181            );
182            table
183        });
184        let records_subscription = cx.subscribe(&records_table, |this, _, event, cx| {
185            this.handle_records_event(event, cx);
186        });
187        let filter_focus = cx.focus_handle();
188        let filter_focus_subscriptions = vec![
189            cx.on_focus(&filter_focus, window, |this, _, _| {
190                this.filter_focus_engaged = true;
191                this.reveal_focused_filter();
192            }),
193            cx.on_blur(&filter_focus, window, |this, _, _| {
194                this.filter_focus_engaged = false;
195            }),
196        ];
197        Self {
198            style: gpui::StyleRefinement::default(),
199            id,
200            label,
201            filters: Arc::from([]),
202            columns: Arc::from([]),
203            rows: Progressive::pending(Arc::from([])),
204            selected_row_id: None,
205            sort_column_id: None,
206            sort_direction: None,
207            records_table,
208            filter_scroll: ScrollHandle::new(),
209            filter_focus,
210            focused_filter_id: None,
211            filter_focus_engaged: false,
212            _records_subscription: records_subscription,
213            _filter_focus_subscriptions: filter_focus_subscriptions,
214        }
215    }
216
217    /// Replaces the controlled filter definitions.
218    ///
219    /// A snapshot containing duplicate stable filter IDs is ignored atomically.
220    pub fn set_filters(
221        &mut self,
222        filters: impl IntoIterator<Item = FilterDefinition>,
223        cx: &mut Context<Self>,
224    ) {
225        let filters = filters.into_iter().collect::<Vec<_>>();
226        let mut seen = HashSet::with_capacity(filters.len());
227        if !filters.iter().all(|filter| seen.insert(filter.id())) {
228            return;
229        }
230        self.filters = filters.into();
231        if self.focused_filter_id.as_ref().is_none_or(|focused| {
232            !self
233                .filters
234                .iter()
235                .any(|filter| filter.id == *focused && !filter.disabled)
236        }) {
237            self.focused_filter_id = self
238                .filters
239                .iter()
240                .find(|filter| !filter.disabled)
241                .map(|filter| filter.id.clone());
242        }
243        if self.filter_focus_engaged {
244            self.reveal_focused_filter();
245        }
246        cx.notify();
247    }
248
249    /// Returns the current controlled filter definitions.
250    pub fn filters(&self) -> &[FilterDefinition] {
251        &self.filters
252    }
253
254    /// Replaces the controlled column snapshot.
255    ///
256    /// A snapshot containing duplicate stable column IDs is ignored atomically.
257    pub fn set_columns(
258        &mut self,
259        columns: impl IntoIterator<Item = FilterColumn>,
260        window: &mut Window,
261        cx: &mut Context<Self>,
262    ) {
263        let columns = columns.into_iter().collect::<Vec<_>>();
264        if !record_columns_have_unique_ids(&columns) {
265            return;
266        }
267        self.columns = columns.into();
268        if self.sort_column_id.as_ref().is_some_and(|sort_column_id| {
269            !self
270                .columns
271                .iter()
272                .any(|column| column.id() == sort_column_id.as_ref() && column.is_sortable())
273        }) {
274            self.sort_column_id = None;
275            self.sort_direction = None;
276        }
277        let columns = self.columns.clone();
278        self.records_table.update(cx, |table, cx| {
279            table.set_columns(columns.iter().cloned(), window, cx);
280        });
281        cx.notify();
282    }
283
284    /// Scrolls the identified filter control into the horizontal viewport.
285    pub fn scroll_to_filter(&mut self, filter_id: &str, cx: &mut Context<Self>) {
286        if let Some(index) = self
287            .filters
288            .iter()
289            .position(|filter| filter.id() == filter_id)
290        {
291            // gpui-component's scrollbar layer occupies the first tracked child.
292            self.filter_scroll.scroll_to_item(index.saturating_add(1));
293            cx.notify();
294        }
295    }
296
297    /// Moves keyboard focus to an enabled stable filter and reveals it.
298    pub fn focus_filter(&mut self, filter_id: &str, window: &mut Window, cx: &mut Context<Self>) {
299        let Some(index) = self
300            .filters
301            .iter()
302            .position(|filter| filter.id() == filter_id && !filter.is_disabled())
303        else {
304            return;
305        };
306        self.focused_filter_id = Some(self.filters[index].id.clone());
307        self.filter_focus_engaged = true;
308        self.filter_scroll.scroll_to_item(index.saturating_add(1));
309        self.filter_focus.focus(window, cx);
310        cx.notify();
311    }
312
313    /// Replaces the controlled progressive rows in their final filtered order.
314    ///
315    /// Duplicate row IDs or duplicate cell column IDs make the complete
316    /// replacement invalid, so the prior controlled snapshot is retained.
317    pub fn set_rows(&mut self, rows: Progressive<Arc<[FilterRow]>>, cx: &mut Context<Self>) {
318        if !record_rows_have_unique_ids(rows.content()) {
319            return;
320        }
321        self.rows = rows.clone();
322        if self.selected_row_id.as_ref().is_some_and(|selected| {
323            !self
324                .rows
325                .content()
326                .iter()
327                .any(|row| row.id() == selected.as_ref() && !row.is_disabled())
328        }) {
329            self.selected_row_id = None;
330        }
331        self.records_table
332            .update(cx, |table, cx| table.set_records_snapshot(rows, cx));
333        cx.notify();
334    }
335
336    /// Replaces the controlled selected row when the stable ID is enabled.
337    pub fn set_selected_row(
338        &mut self,
339        row_id: impl Into<SharedString>,
340        window: &mut Window,
341        cx: &mut Context<Self>,
342    ) {
343        let row_id = row_id.into();
344        if !self
345            .rows
346            .content()
347            .iter()
348            .any(|row| row.id() == row_id.as_ref() && !row.is_disabled())
349        {
350            return;
351        }
352        self.selected_row_id = Some(row_id.clone());
353        self.records_table.update(cx, |table, cx| {
354            table.set_selected_row(row_id, window, cx);
355        });
356        cx.notify();
357    }
358
359    /// Clears the controlled selected row.
360    pub fn clear_selected_row(&mut self, cx: &mut Context<Self>) {
361        self.selected_row_id = None;
362        self.records_table
363            .update(cx, |table, cx| table.clear_selected_row(cx));
364        cx.notify();
365    }
366
367    /// Returns the controlled selected row ID.
368    pub fn selected_row_id(&self) -> Option<&str> {
369        self.selected_row_id.as_deref()
370    }
371
372    /// Replaces the controlled sort projection.
373    pub fn set_sort(
374        &mut self,
375        column_id: impl Into<SharedString>,
376        direction: Option<FilterSortDirection>,
377        window: &mut Window,
378        cx: &mut Context<Self>,
379    ) {
380        let column_id = column_id.into();
381        if direction.is_some()
382            && !self
383                .columns
384                .iter()
385                .any(|column| column.id() == column_id.as_ref() && column.is_sortable())
386        {
387            return;
388        }
389        self.sort_column_id = direction.map(|_| column_id.clone());
390        self.sort_direction = direction;
391        self.records_table.update(cx, |table, cx| {
392            table.set_sort(column_id, direction, window, cx);
393        });
394        cx.notify();
395    }
396
397    /// Returns the controlled sort column ID and direction.
398    pub fn sort(&self) -> Option<(&str, FilterSortDirection)> {
399        self.sort_column_id.as_deref().zip(self.sort_direction)
400    }
401
402    /// Scrolls the identified row into view when it exists.
403    pub fn scroll_to_row(&mut self, row_id: &str, cx: &mut Context<Self>) {
404        self.records_table
405            .update(cx, |table, cx| table.scroll_to_row(row_id, cx));
406    }
407
408    /// Scrolls the identified column into view when it exists.
409    pub fn scroll_to_column(&mut self, column_id: &str, cx: &mut Context<Self>) {
410        self.records_table
411            .update(cx, |table, cx| table.scroll_to_column(column_id, cx));
412    }
413
414    /// Returns the number of stable rows constructed for the current virtual viewport.
415    pub fn visible_row_count(&self, cx: &App) -> usize {
416        self.records_table.read(cx).visible_row_count(cx)
417    }
418
419    /// Returns the bounded number of visible rows carrying active reorder state.
420    pub fn animating_row_count(&self, cx: &App) -> usize {
421        self.records_table.read(cx).animating_row_count(cx)
422    }
423
424    /// Moves keyboard focus to the virtualized grid.
425    pub fn focus(&self, window: &mut Window, cx: &mut Context<Self>) {
426        self.records_table.focus_handle(cx).focus(window, cx);
427    }
428
429    fn request_filter(&mut self, filter_id: SharedString, cx: &mut Context<Self>) {
430        let Some(filter) = self
431            .filters
432            .iter()
433            .find(|filter| filter.id == filter_id && !filter.disabled)
434        else {
435            return;
436        };
437        cx.emit(FilterTableEvent::FilterRequested {
438            id: self.id.clone(),
439            filter_id,
440            active: !filter.active,
441        });
442    }
443
444    fn move_filter_focus(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
445        let enabled = self
446            .filters
447            .iter()
448            .enumerate()
449            .filter(|(_, filter)| !filter.disabled)
450            .map(|(index, _)| index)
451            .collect::<Vec<_>>();
452        if enabled.is_empty() {
453            return;
454        }
455        let current = self
456            .focused_filter_id
457            .as_ref()
458            .and_then(|focused| self.filters.iter().position(|filter| filter.id == *focused))
459            .and_then(|index| enabled.iter().position(|enabled| *enabled == index))
460            .unwrap_or_default();
461        let next = (current as isize + delta).rem_euclid(enabled.len() as isize) as usize;
462        let index = enabled[next];
463        self.focused_filter_id = Some(self.filters[index].id.clone());
464        self.filter_focus_engaged = true;
465        self.filter_scroll.scroll_to_item(index.saturating_add(1));
466        self.filter_focus.focus(window, cx);
467        cx.notify();
468    }
469
470    fn reveal_focused_filter(&self) {
471        let Some(index) = self.focused_filter_id.as_ref().and_then(|focused| {
472            self.filters
473                .iter()
474                .position(|filter| filter.id == *focused && !filter.disabled)
475        }) else {
476            return;
477        };
478        // gpui-component's scrollbar layer occupies the first tracked child.
479        self.filter_scroll.scroll_to_item(index.saturating_add(1));
480    }
481
482    fn handle_records_event(&mut self, event: &RecordsTableEvent, cx: &mut Context<Self>) {
483        match event {
484            RecordsTableEvent::SelectionRequested { row_id, .. } => {
485                cx.emit(FilterTableEvent::SelectionRequested {
486                    id: self.id.clone(),
487                    row_id: row_id.clone(),
488                });
489            }
490            RecordsTableEvent::ActivationRequested { row_id, .. } => {
491                cx.emit(FilterTableEvent::ActivationRequested {
492                    id: self.id.clone(),
493                    row_id: row_id.clone(),
494                });
495            }
496            RecordsTableEvent::SortRequested {
497                column_id,
498                direction,
499                ..
500            } => cx.emit(FilterTableEvent::SortRequested {
501                id: self.id.clone(),
502                column_id: column_id.clone(),
503                direction: *direction,
504            }),
505        }
506    }
507}
508
509impl EventEmitter<FilterTableEvent> for FilterTable {}
510
511impl Focusable for FilterTable {
512    fn focus_handle(&self, cx: &App) -> FocusHandle {
513        self.records_table.focus_handle(cx)
514    }
515}
516
517impl gpui::Styled for FilterTable {
518    fn style(&mut self) -> &mut gpui::StyleRefinement {
519        &mut self.style
520    }
521}
522
523impl Render for FilterTable {
524    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
525        let tokens = cx.theme().semantic_tokens();
526        let result_label: SharedString = format!("{} results", self.rows.content().len()).into();
527        let owner = cx.weak_entity();
528        let navigation_owner = owner.clone();
529        div()
530            .id(scoped_filter_id("root", &self.id, "surface"))
531            .size_full()
532            .min_h_0()
533            .flex()
534            .flex_col()
535            .gap(tokens.spacing.xs)
536            .role(Role::Group)
537            .aria_label(self.label.clone())
538            .child(
539                div()
540                    .relative()
541                    .w_full()
542                    .flex_none()
543                    .child(
544                        filter_controls_frame(&self.id, &self.label)
545                            .w_full()
546                            .min_w_0()
547                            .flex()
548                            .items_center()
549                            .gap(tokens.spacing.xs)
550                            .overflow_x_scroll()
551                            .lock_scroll_axis()
552                            .track_scroll(&self.filter_scroll)
553                            .policy_horizontal_scrollbar(&self.filter_scroll, cx)
554                            .children(self.filters.iter().map(|filter| {
555                                let filter_id = filter.id.clone();
556                                let handler_owner = owner.clone();
557                                let is_focused =
558                                    self.focused_filter_id.as_ref() == Some(&filter.id);
559                                filter_control(&self.id, filter, window, cx)
560                                    .tab_stop(is_focused)
561                                    .when(is_focused, |button| {
562                                        button.track_focus(&self.filter_focus)
563                                    })
564                                    .on_click(move |_, _, cx| {
565                                        let _ = handler_owner.update(cx, |table, cx| {
566                                            table.request_filter(filter_id.clone(), cx);
567                                        });
568                                    })
569                            }))
570                            .on_key_down(move |event, window, cx| {
571                                let delta = match event.keystroke.key.as_str() {
572                                    "left" => -1,
573                                    "right" => 1,
574                                    _ => return,
575                                };
576                                let _ = navigation_owner.update(cx, |table, cx| {
577                                    table.move_filter_focus(delta, window, cx);
578                                });
579                                cx.stop_propagation();
580                            }),
581                    )
582                    .child(
583                        ScrollableMask::new(Axis::Horizontal, &self.filter_scroll)
584                            .id((gpui::ElementId::from(self.id.clone()), "filter-scroll-mask")),
585                    ),
586            )
587            .child(
588                filter_results_frame(&self.id, result_label.clone())
589                    .flex_none()
590                    .text_token(tokens.typography.xs)
591                    .text_color(cx.theme().muted_foreground)
592                    .child(result_label),
593            )
594            .child(div().flex_1().min_h_0().child(self.records_table.clone()))
595            .refine_style(&self.style)
596    }
597}
598
599fn filter_controls_frame(table_id: &str, label: &str) -> Stateful<Div> {
600    let debug_id = scoped_filter_id("controls", table_id, "filters");
601    div()
602        .id(debug_id.clone())
603        .debug_selector(move || debug_id.to_string())
604        .role(Role::Group)
605        .aria_label(format!("Filters for {label}"))
606}
607
608fn filter_results_frame(table_id: &str, result_label: SharedString) -> Stateful<Div> {
609    div()
610        .id(scoped_filter_id("results", table_id, "status"))
611        .role(Role::Status)
612        .aria_label(result_label)
613}
614
615fn filter_control(
616    table_id: &str,
617    filter: &FilterDefinition,
618    window: &mut Window,
619    cx: &mut App,
620) -> gpui_base::Button {
621    let visible_label = format!("{} {}", filter.label, filter.count);
622    let state = if filter.active { "active" } else { "inactive" };
623    let accessibility_label = if filter.disabled {
624        format!("Unavailable: {visible_label}, {state}")
625    } else {
626        format!("{visible_label}, {state}")
627    };
628    let debug_id = scoped_filter_id("filter", table_id, &filter.id);
629    outlined_control_with_label(
630        debug_id.clone(),
631        accessibility_label,
632        visible_label,
633        window,
634        cx,
635    )
636    .debug_selector(move || debug_id.to_string())
637    .aria_toggled(if filter.active {
638        accesskit::Toggled::True
639    } else {
640        accesskit::Toggled::False
641    })
642    .disabled(filter.disabled)
643    .when(filter.active, |button| {
644        button
645            .bg(cx.theme().primary.opacity(0.12))
646            .border_color(cx.theme().primary)
647            .text_color(cx.theme().primary)
648    })
649}
650
651fn scoped_filter_id(kind: &str, table_id: &str, item_id: &str) -> SharedString {
652    format!("filter-table-{kind}-{}:{table_id}{item_id}", table_id.len()).into()
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use gpui::{Element as _, IntoElement as _, RenderOnce as _, TestAppContext, canvas};
659    use std::sync::{Arc, Mutex};
660
661    type CapturedFilters = Arc<Mutex<Vec<(Option<Role>, accesskit::Node)>>>;
662
663    struct FilterControlProbe {
664        captured: CapturedFilters,
665    }
666
667    impl Render for FilterControlProbe {
668        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl gpui::IntoElement {
669            let captured = self.captured.clone();
670            canvas(
671                move |_, window, cx| {
672                    let controls = [
673                        filter_control(
674                            "tasks",
675                            &FilterDefinition::new("todo", "To do", 2).active(true),
676                            window,
677                            cx,
678                        )
679                        .on_click(|_, _, _| {})
680                        .render(window, cx)
681                        .into_element(),
682                        filter_control(
683                            "tasks",
684                            &FilterDefinition::new("blocked", "Blocked", 0).disabled(true),
685                            window,
686                            cx,
687                        )
688                        .on_click(|_, _, _| {})
689                        .render(window, cx)
690                        .into_element(),
691                    ];
692                    let frames = [
693                        filter_controls_frame("tasks", "Task board"),
694                        filter_results_frame("tasks", "2 results".into()),
695                    ];
696                    let nodes = controls
697                        .into_iter()
698                        .map(|element| {
699                            let role = element.a11y_role();
700                            let mut node = accesskit::Node::new(Role::Unknown);
701                            element.write_a11y_info(&mut node);
702                            (role, node)
703                        })
704                        .chain(frames.into_iter().map(|element| {
705                            let role = element.a11y_role();
706                            let mut node = accesskit::Node::new(Role::Unknown);
707                            element.write_a11y_info(&mut node);
708                            (role, node)
709                        }))
710                        .collect();
711                    *captured.lock().expect("capture mutex should be available") = nodes;
712                },
713                |_, _, _, _| {},
714            )
715        }
716    }
717
718    #[gpui::test]
719    fn filter_controls_expose_name_state_and_available_action(cx: &mut TestAppContext) {
720        cx.update(crate::init);
721        let captured = CapturedFilters::default();
722        let result = captured.clone();
723        let (_, cx) = cx.add_window_view(|_, _| FilterControlProbe { captured });
724        cx.update(|window, cx| window.draw(cx).clear(cx));
725        let controls = result.lock().expect("capture mutex should be available");
726
727        assert_eq!(controls[0].0, Some(Role::Button));
728        assert_eq!(controls[0].1.label(), Some("To do 2, active"));
729        assert_eq!(controls[0].1.toggled(), Some(accesskit::Toggled::True));
730        assert!(controls[0].1.supports_action(accesskit::Action::Click));
731
732        assert_eq!(controls[1].0, Some(Role::Button));
733        assert_eq!(
734            controls[1].1.label(),
735            Some("Unavailable: Blocked 0, inactive")
736        );
737        assert_eq!(controls[1].1.toggled(), Some(accesskit::Toggled::False));
738        assert!(!controls[1].1.supports_action(accesskit::Action::Click));
739
740        assert_eq!(controls[2].0, Some(Role::Group));
741        assert_eq!(controls[2].1.label(), Some("Filters for Task board"));
742        assert_eq!(controls[3].0, Some(Role::Status));
743        assert_eq!(controls[3].1.label(), Some("2 results"));
744    }
745}