1use 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#[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 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 pub fn active(mut self, active: bool) -> Self {
57 self.active = active;
58 self
59 }
60
61 pub fn disabled(mut self, disabled: bool) -> Self {
63 self.disabled = disabled;
64 self
65 }
66
67 pub fn id(&self) -> &str {
69 self.id.as_ref()
70 }
71
72 pub fn label(&self) -> &str {
74 self.label.as_ref()
75 }
76
77 pub fn count(&self) -> usize {
79 self.count
80 }
81
82 pub fn is_active(&self) -> bool {
84 self.active
85 }
86
87 pub fn is_disabled(&self) -> bool {
89 self.disabled
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum FilterTableEvent {
96 FilterRequested {
98 id: SharedString,
100 filter_id: SharedString,
102 active: bool,
104 },
105 SelectionRequested {
107 id: SharedString,
109 row_id: SharedString,
111 },
112 ActivationRequested {
114 id: SharedString,
116 row_id: SharedString,
118 },
119 SortRequested {
121 id: SharedString,
123 column_id: SharedString,
125 direction: Option<FilterSortDirection>,
127 },
128}
129
130pub struct FilterTable {
137 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 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 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 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 pub fn filters(&self) -> &[FilterDefinition] {
251 &self.filters
252 }
253
254 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 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 self.filter_scroll.scroll_to_item(index.saturating_add(1));
293 cx.notify();
294 }
295 }
296
297 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 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 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 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 pub fn selected_row_id(&self) -> Option<&str> {
369 self.selected_row_id.as_deref()
370 }
371
372 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 pub fn sort(&self) -> Option<(&str, FilterSortDirection)> {
399 self.sort_column_id.as_deref().zip(self.sort_direction)
400 }
401
402 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 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 pub fn visible_row_count(&self, cx: &App) -> usize {
416 self.records_table.read(cx).visible_row_count(cx)
417 }
418
419 pub fn animating_row_count(&self, cx: &App) -> usize {
421 self.records_table.read(cx).animating_row_count(cx)
422 }
423
424 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 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}