csv_preview: Refine filter popover UI (#61138)
# Objective
- The column filter popover was a static `ContextMenu`: no search, and
it didn't scale to columns with many unique values.
## Solution
- Replaced it with a `Picker<ColumnFilterDelegate>` with fuzzy search
and match highlighting over the column's unique values.
- Row order is frozen at open time (available first, then values hidden
by other filters under a "Hidden by other filters" header) so toggling a
value doesn't reshuffle the list.
- Added a footer with `{selected} / {total} rows selected` and "Clear
all".
- Added `fuzzy` and `picker` deps to `csv_preview`.
## Testing
- Search narrows to fuzzy matches with highlighting.
- Toggling a value updates its checkmark and footer count without
closing or reordering the list.
- Values excluded by another column's filter show disabled under "Hidden
by other filters".
- "Clear all" clears the column's filters and keeps the popover open.
## Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
## Showcase
| Before | After |
| --- | --- |
| <img width="660" height="351" alt="image"
src="https://github.com/user-attachments/assets/c78084a2-d327-4d48-9c05-f35383455713"
/> | <img width="660" height="354" alt="image"
src="https://github.com/user-attachments/assets/910d52ef-4530-4ac7-91ac-3db318cf6c19"
/> |
**+ search**
<img width="972" height="574" alt="image"
src="https://github.com/user-attachments/assets/1a26d0fc-c96c-4973-818d-7d053e8cce73"
/>
---
Release Notes:
- N/A
This commit is contained in:
1 parent
0fd17feb17
commit
efc3ba73b5
3 files changed
+532
-161
No files matched your search
Generated
+2
@@ -4580,8 +4580,10 @@ dependencies = [
|
||||
"anyhow",
|
||||
"editor",
|
||||
"feature_flags",
|
||||
"fuzzy",
|
||||
"gpui",
|
||||
"log",
|
||||
"picker",
|
||||
"text",
|
||||
"ui",
|
||||
"workspace",
|
||||
|
||||
@@ -10,8 +10,10 @@ path = "src/csv_preview.rs"
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
feature_flags.workspace = true
|
||||
fuzzy.workspace = true
|
||||
gpui.workspace = true
|
||||
editor.workspace = true
|
||||
picker.workspace = true
|
||||
ui.workspace = true
|
||||
workspace.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
use gpui::ElementId;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
|
||||
use gpui::{
|
||||
BackgroundExecutor, DismissEvent, ElementId, Entity, Focusable, ForegroundExecutor, Task,
|
||||
};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use ui::{
|
||||
ContextMenu, GradientFade, IconButton, IconName, IconSize, PopoverMenu, Tooltip, prelude::*,
|
||||
Color, GradientFade, HighlightedLabel, Icon, IconButton, IconName, IconSize, Label, LabelSize,
|
||||
ListItem, ListItemSpacing, PopoverMenu, Tooltip, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -13,6 +26,494 @@ use crate::{
|
||||
types::AnyColumn,
|
||||
};
|
||||
|
||||
struct ColumnFilterRow {
|
||||
entry: FilterEntry,
|
||||
is_applied: bool,
|
||||
/// Set when this value is hidden because another column's active filter
|
||||
/// excludes every row containing it.
|
||||
hidden_by: Option<AnyColumn>,
|
||||
}
|
||||
|
||||
enum ColumnFilterListEntry {
|
||||
Header(SharedString),
|
||||
Row {
|
||||
row_index: usize,
|
||||
positions: Vec<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
struct ColumnFilterDelegate {
|
||||
col: AnyColumn,
|
||||
view: Entity<CsvPreviewView>,
|
||||
/// Row order frozen at open time (available entries sorted per the
|
||||
/// column's `FilterSortOrder`, then entries hidden by other columns'
|
||||
/// filters). Kept stable so toggling a value doesn't reshuffle the list
|
||||
/// under the user's cursor; only `is_applied`/`hidden_by`/counts are
|
||||
/// refreshed in place after a toggle.
|
||||
rows: Vec<ColumnFilterRow>,
|
||||
/// Number of available (non-hidden) rows, shown in the search placeholder.
|
||||
available_count: usize,
|
||||
string_candidates: Arc<Vec<StringMatchCandidate>>,
|
||||
filtered: Vec<ColumnFilterListEntry>,
|
||||
selected_index: usize,
|
||||
query: String,
|
||||
foreground: ForegroundExecutor,
|
||||
background: BackgroundExecutor,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl ColumnFilterDelegate {
|
||||
fn new(
|
||||
col: AnyColumn,
|
||||
view: Entity<CsvPreviewView>,
|
||||
sort_order: FilterSortOrder,
|
||||
column_filters: Arc<Vec<(FilterEntry, FilterEntryState)>>,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Self {
|
||||
let mut available: Vec<(FilterEntry, bool)> = Vec::new();
|
||||
let mut hidden: Vec<(FilterEntry, AnyColumn)> = Vec::new();
|
||||
for (entry, state) in column_filters.iter() {
|
||||
match state {
|
||||
FilterEntryState::Available { is_applied } => {
|
||||
available.push((entry.clone(), *is_applied))
|
||||
}
|
||||
FilterEntryState::Unavailable { blocked_by } => {
|
||||
hidden.push((entry.clone(), *blocked_by))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match sort_order {
|
||||
FilterSortOrder::AlphaThenCount => available.sort_by(|(a, a_app), (b, b_app)| {
|
||||
b_app
|
||||
.cmp(a_app)
|
||||
.then_with(|| a.content.cmp(&b.content))
|
||||
.then_with(|| b.occurred_times().cmp(&a.occurred_times()))
|
||||
}),
|
||||
FilterSortOrder::CountThenAlpha => available.sort_by(|(a, a_app), (b, b_app)| {
|
||||
b_app
|
||||
.cmp(a_app)
|
||||
.then_with(|| b.occurred_times().cmp(&a.occurred_times()))
|
||||
.then_with(|| a.content.cmp(&b.content))
|
||||
}),
|
||||
}
|
||||
|
||||
let available_count = available.len();
|
||||
|
||||
let rows: Vec<ColumnFilterRow> = available
|
||||
.into_iter()
|
||||
.map(|(entry, is_applied)| ColumnFilterRow {
|
||||
entry,
|
||||
is_applied,
|
||||
hidden_by: None,
|
||||
})
|
||||
.chain(
|
||||
hidden
|
||||
.into_iter()
|
||||
.map(|(entry, blocked_by)| ColumnFilterRow {
|
||||
entry,
|
||||
is_applied: false,
|
||||
hidden_by: Some(blocked_by),
|
||||
}),
|
||||
)
|
||||
.collect();
|
||||
|
||||
let string_candidates = Arc::new(
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| {
|
||||
StringMatchCandidate::new(index, &Self::display_text(&row.entry))
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
let filtered = Self::build_entries(
|
||||
&rows,
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.map(|(index, _)| (index, Vec::new()))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
Self {
|
||||
col,
|
||||
view,
|
||||
rows,
|
||||
available_count,
|
||||
string_candidates,
|
||||
filtered,
|
||||
selected_index: 0,
|
||||
query: String::new(),
|
||||
foreground: cx.foreground_executor().clone(),
|
||||
background: cx.background_executor().clone(),
|
||||
cancel: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_text(entry: &FilterEntry) -> String {
|
||||
match &entry.content {
|
||||
Some(s) => s.as_ref().to_owned(),
|
||||
None => "<null>".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Assembles the flat list shown to the user from matched
|
||||
/// `(row_index, positions)` pairs (which must be sorted ascending by
|
||||
/// `row_index`, matching `rows`' available-then-hidden order), inserting a
|
||||
/// "Hidden by other filters" header before the first hidden row.
|
||||
fn build_entries(
|
||||
rows: &[ColumnFilterRow],
|
||||
matches: Vec<(usize, Vec<usize>)>,
|
||||
) -> Vec<ColumnFilterListEntry> {
|
||||
let mut entries = Vec::with_capacity(matches.len() + 1);
|
||||
let mut header_inserted = false;
|
||||
for (row_index, positions) in matches {
|
||||
if rows[row_index].hidden_by.is_some() && !header_inserted {
|
||||
entries.push(ColumnFilterListEntry::Header(
|
||||
"Hidden by other filters".into(),
|
||||
));
|
||||
header_inserted = true;
|
||||
}
|
||||
entries.push(ColumnFilterListEntry::Row {
|
||||
row_index,
|
||||
positions,
|
||||
});
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn first_selectable_index(&self) -> usize {
|
||||
self.filtered
|
||||
.iter()
|
||||
.position(|entry| {
|
||||
matches!(entry, ColumnFilterListEntry::Row { row_index, .. }
|
||||
if self.rows[*row_index].hidden_by.is_none())
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn matches_for_query(&self, query: &str) -> Vec<(usize, Vec<usize>)> {
|
||||
if query.is_empty() {
|
||||
return self
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, _)| (index, Vec::new()))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let cancel_flag = AtomicBool::new(false);
|
||||
let mut matches: Vec<StringMatch> = self.foreground.block_on(match_strings(
|
||||
self.string_candidates.as_ref(),
|
||||
query,
|
||||
false,
|
||||
true,
|
||||
usize::MAX,
|
||||
&cancel_flag,
|
||||
self.background.clone(),
|
||||
));
|
||||
matches.sort_by_key(|m| m.candidate_id);
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|m| (m.candidate_id, m.positions))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Re-fetches this column's filter entries from the engine (e.g. after a
|
||||
/// toggle changes counts/availability across the cascade) while keeping
|
||||
/// `rows`' frozen order, then re-applies the current search query.
|
||||
fn refresh_rows(&mut self, cx: &mut Context<Picker<Self>>) {
|
||||
let column_filters = match self.view.read(cx).engine.get_filters_for_column(self.col) {
|
||||
Ok(filters) => filters,
|
||||
Err(err) => {
|
||||
log::error!("Failed to get filters for column: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut lookup: HashMap<Option<SharedString>, (FilterEntry, FilterEntryState)> =
|
||||
column_filters
|
||||
.iter()
|
||||
.map(|(entry, state)| (entry.content.clone(), (entry.clone(), *state)))
|
||||
.collect();
|
||||
|
||||
for row in &mut self.rows {
|
||||
let Some((entry, state)) = lookup.remove(&row.entry.content) else {
|
||||
continue;
|
||||
};
|
||||
row.entry = entry;
|
||||
match state {
|
||||
FilterEntryState::Available { is_applied } => {
|
||||
row.is_applied = is_applied;
|
||||
row.hidden_by = None;
|
||||
}
|
||||
FilterEntryState::Unavailable { blocked_by } => {
|
||||
row.is_applied = false;
|
||||
row.hidden_by = Some(blocked_by);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.filtered = Self::build_entries(&self.rows, self.matches_for_query(&self.query));
|
||||
self.selected_index = self
|
||||
.selected_index
|
||||
.min(self.filtered.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for ColumnFilterDelegate {
|
||||
type ListItem = AnyElement;
|
||||
|
||||
fn name() -> &'static str {
|
||||
"csv column filter"
|
||||
}
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) {
|
||||
self.selected_index = ix.min(self.filtered.len().saturating_sub(1));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
|
||||
match self.filtered.get(ix) {
|
||||
Some(ColumnFilterListEntry::Row { row_index, .. }) => {
|
||||
self.rows[*row_index].hidden_by.is_none()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
format!("Search {} unique values…", self.available_count).into()
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
self.query = query.clone();
|
||||
|
||||
if query.is_empty() {
|
||||
self.filtered = Self::build_entries(&self.rows, self.matches_for_query(&query));
|
||||
self.selected_index = self.first_selectable_index();
|
||||
cx.notify();
|
||||
return Task::ready(());
|
||||
}
|
||||
|
||||
if let Some(prev) = &self.cancel {
|
||||
prev.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
self.cancel = Some(cancel.clone());
|
||||
|
||||
let string_candidates = self.string_candidates.clone();
|
||||
let background = self.background.clone();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let mut matches = match_strings(
|
||||
string_candidates.as_ref(),
|
||||
&query,
|
||||
false,
|
||||
true,
|
||||
usize::MAX,
|
||||
cancel.as_ref(),
|
||||
background,
|
||||
)
|
||||
.await;
|
||||
matches.sort_by_key(|m| m.candidate_id);
|
||||
|
||||
this.update_in(cx, |this, _, cx| {
|
||||
if this.delegate.query != query {
|
||||
return;
|
||||
}
|
||||
let rows = &this.delegate.rows;
|
||||
let matches = matches
|
||||
.into_iter()
|
||||
.map(|m| (m.candidate_id, m.positions))
|
||||
.collect();
|
||||
this.delegate.filtered = ColumnFilterDelegate::build_entries(rows, matches);
|
||||
this.delegate.selected_index = this.delegate.first_selectable_index();
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
let Some(ColumnFilterListEntry::Row { row_index, .. }) =
|
||||
self.filtered.get(self.selected_index)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let row_index = *row_index;
|
||||
if self.rows[row_index].hidden_by.is_some() {
|
||||
return;
|
||||
}
|
||||
let col = self.col;
|
||||
let value = self.rows[row_index].entry.content.clone();
|
||||
self.view
|
||||
.update(cx, |view, cx| view.toggle_filter(col, value, cx));
|
||||
self.refresh_rows(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
match self.filtered.get(ix)? {
|
||||
ColumnFilterListEntry::Header(label) => Some(
|
||||
div()
|
||||
.px_2()
|
||||
.pt_2()
|
||||
.pb_1()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(
|
||||
Label::new(label.clone())
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
ColumnFilterListEntry::Row {
|
||||
row_index,
|
||||
positions,
|
||||
} => {
|
||||
let row = &self.rows[*row_index];
|
||||
let value_text: SharedString = match &row.entry.content {
|
||||
Some(s) => s.clone(),
|
||||
None => "<null>".into(),
|
||||
};
|
||||
let count_text = SharedString::from(row.entry.occurred_times().to_string());
|
||||
let label = HighlightedLabel::new(value_text.clone(), positions.clone())
|
||||
.size(LabelSize::Small)
|
||||
.single_line()
|
||||
.truncate();
|
||||
|
||||
if row.hidden_by.is_some() {
|
||||
return Some(
|
||||
ListItem::new(("csv-filter-hidden", ix))
|
||||
.disabled(true)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.child(label.color(Color::Disabled))
|
||||
.end_slot(
|
||||
Label::new(count_text)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Disabled),
|
||||
)
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
|
||||
Some(
|
||||
ListItem::new(("csv-filter-value", ix))
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(selected)
|
||||
.start_slot(
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.when(!row.is_applied, |el| el.invisible())
|
||||
.child(
|
||||
Icon::new(IconName::Check)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Accent),
|
||||
),
|
||||
)
|
||||
.child(label)
|
||||
.end_slot(
|
||||
Label::new(count_text)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.tooltip(Tooltip::text(value_text))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_footer(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<AnyElement> {
|
||||
let selected_rows: usize = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|row| row.hidden_by.is_none() && row.is_applied)
|
||||
.map(|row| row.entry.occurred_times())
|
||||
.sum();
|
||||
let total_rows: usize = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|row| row.hidden_by.is_none())
|
||||
.map(|row| row.entry.occurred_times())
|
||||
.sum();
|
||||
if selected_rows == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let col = self.col;
|
||||
Some(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(
|
||||
Label::new(format!("{selected_rows} / {total_rows} rows selected"))
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("csv-filter-clear-all")
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
Label::new("Clear all")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Accent),
|
||||
)
|
||||
.on_click(cx.listener(move |picker, _, _, cx| {
|
||||
picker
|
||||
.delegate
|
||||
.view
|
||||
.update(cx, |view, cx| view.clear_filters(col, cx));
|
||||
picker.delegate.refresh_rows(cx);
|
||||
cx.notify();
|
||||
cx.emit(DismissEvent);
|
||||
})),
|
||||
)
|
||||
.into_any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl CsvPreviewView {
|
||||
/// Create header for data, which is orderable with text on the left and sort button on the right
|
||||
pub(crate) fn create_header_element_with_sort_button(
|
||||
@@ -83,14 +584,14 @@ impl CsvPreviewView {
|
||||
cx: &mut Context<'_, CsvPreviewView>,
|
||||
col_idx: AnyColumn,
|
||||
) -> Button {
|
||||
let sort_btn = Button::new(
|
||||
Button::new(
|
||||
ElementId::NamedInteger("sort-button".into(), col_idx.get() as u64),
|
||||
match self.engine.applied_sorting {
|
||||
Some(ordering) if ordering.col_idx == col_idx => match ordering.direction {
|
||||
SortDirection::Asc => "↓",
|
||||
SortDirection::Desc => "↑",
|
||||
},
|
||||
_ => "↕", // Unsorted/available for sorting
|
||||
_ => "↕",
|
||||
},
|
||||
)
|
||||
.size(ButtonSize::Compact)
|
||||
@@ -114,38 +615,31 @@ impl CsvPreviewView {
|
||||
}))
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
let new_sorting = match this.engine.applied_sorting {
|
||||
Some(ordering) if ordering.col_idx == col_idx => {
|
||||
// Same column clicked - cycle through states
|
||||
match ordering.direction {
|
||||
SortDirection::Asc => Some(AppliedSorting {
|
||||
col_idx,
|
||||
direction: SortDirection::Desc,
|
||||
}),
|
||||
SortDirection::Desc => None, // Clear sorting
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Different column or no sorting - start with ascending
|
||||
Some(AppliedSorting {
|
||||
Some(ordering) if ordering.col_idx == col_idx => match ordering.direction {
|
||||
SortDirection::Asc => Some(AppliedSorting {
|
||||
col_idx,
|
||||
direction: SortDirection::Asc,
|
||||
})
|
||||
}
|
||||
direction: SortDirection::Desc,
|
||||
}),
|
||||
SortDirection::Desc => None,
|
||||
},
|
||||
_ => Some(AppliedSorting {
|
||||
col_idx,
|
||||
direction: SortDirection::Asc,
|
||||
}),
|
||||
};
|
||||
|
||||
this.engine.applied_sorting = new_sorting;
|
||||
this.apply_sort(cx);
|
||||
cx.notify();
|
||||
}));
|
||||
sort_btn
|
||||
}))
|
||||
}
|
||||
|
||||
fn create_filter_button(
|
||||
&self,
|
||||
cx: &mut Context<'_, CsvPreviewView>,
|
||||
col: AnyColumn,
|
||||
) -> PopoverMenu<ContextMenu> {
|
||||
) -> PopoverMenu<Picker<ColumnFilterDelegate>> {
|
||||
let has_active_filters = self.engine.has_active_filters(col);
|
||||
let sort_order = self.settings.filter_sort_order;
|
||||
|
||||
PopoverMenu::new(ElementId::NamedInteger(
|
||||
"filter-menu".into(),
|
||||
@@ -180,146 +674,19 @@ impl CsvPreviewView {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let filter_sort_order = view.settings.filter_sort_order;
|
||||
let filter_menu = Self::create_filter_menu(
|
||||
window,
|
||||
cx,
|
||||
view_entity.clone(),
|
||||
col,
|
||||
&column_filters,
|
||||
has_active_filters,
|
||||
filter_sort_order,
|
||||
);
|
||||
Some(filter_menu)
|
||||
}
|
||||
})
|
||||
}
|
||||
let view_entity = view_entity.clone();
|
||||
|
||||
fn create_filter_menu(
|
||||
window: &mut ui::Window,
|
||||
cx: &mut ui::App,
|
||||
view_entity: gpui::Entity<CsvPreviewView>,
|
||||
col: AnyColumn,
|
||||
column_filters: &[(FilterEntry, FilterEntryState)],
|
||||
has_active_filters: bool,
|
||||
sort_order: FilterSortOrder,
|
||||
) -> gpui::Entity<ContextMenu> {
|
||||
let mut available: Vec<&FilterEntry> = column_filters
|
||||
.iter()
|
||||
.filter_map(|(entry, state)| {
|
||||
matches!(state, FilterEntryState::Available { .. }).then_some(entry)
|
||||
})
|
||||
.collect();
|
||||
|
||||
match sort_order {
|
||||
FilterSortOrder::AlphaThenCount => available.sort_by(|a, b| {
|
||||
a.content
|
||||
.cmp(&b.content)
|
||||
.then_with(|| b.occurred_times().cmp(&a.occurred_times()))
|
||||
}),
|
||||
FilterSortOrder::CountThenAlpha => available.sort_by(|a, b| {
|
||||
b.occurred_times()
|
||||
.cmp(&a.occurred_times())
|
||||
.then_with(|| a.content.cmp(&b.content))
|
||||
}),
|
||||
}
|
||||
|
||||
let unavailable: Vec<(&FilterEntry, AnyColumn)> = column_filters
|
||||
.iter()
|
||||
.filter_map(|(entry, state)| {
|
||||
if let FilterEntryState::Unavailable { blocked_by } = state {
|
||||
Some((entry, *blocked_by))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Pre-build applied-state lookup before moving into the closure
|
||||
let applied_states: Vec<(FilterEntry, bool)> = column_filters
|
||||
.iter()
|
||||
.filter_map(|(entry, state)| {
|
||||
if let FilterEntryState::Available { is_applied } = state {
|
||||
Some((entry.clone(), *is_applied))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let available_cloned: Vec<FilterEntry> = available.iter().map(|e| (*e).clone()).collect();
|
||||
let unavailable_cloned: Vec<(FilterEntry, AnyColumn)> = unavailable
|
||||
.into_iter()
|
||||
.map(|(e, col)| (e.clone(), col))
|
||||
.collect();
|
||||
|
||||
ContextMenu::build(window, cx, move |menu, _, _| {
|
||||
let mut menu = menu;
|
||||
|
||||
if has_active_filters {
|
||||
menu = menu
|
||||
.toggleable_entry("Clear all", false, ui::IconPosition::Start, None, {
|
||||
let view_entity = view_entity.clone();
|
||||
move |_window, cx| {
|
||||
view_entity.update(cx, |view, cx| {
|
||||
view.clear_filters(col, cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.separator();
|
||||
}
|
||||
|
||||
for entry in &available_cloned {
|
||||
let is_applied = applied_states
|
||||
.iter()
|
||||
.find(|(e, _)| e.content == entry.content)
|
||||
.map_or(false, |(_, applied)| *applied);
|
||||
|
||||
let label: SharedString =
|
||||
format_filter_label(entry.content.as_ref(), entry.occurred_times()).into();
|
||||
let entry_value = entry.content.clone();
|
||||
|
||||
menu = menu.toggleable_entry(&label, is_applied, ui::IconPosition::Start, None, {
|
||||
let view_entity = view_entity.clone();
|
||||
move |_window, cx| {
|
||||
view_entity.update(cx, |view, cx| {
|
||||
view.toggle_filter(col, entry_value.clone(), cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
let picker = cx.new(|cx| {
|
||||
let delegate =
|
||||
ColumnFilterDelegate::new(col, view_entity, sort_order, column_filters, cx);
|
||||
Picker::list(delegate, window, cx)
|
||||
.popover()
|
||||
.initial_width(rems(18.75))
|
||||
.show_scrollbar(true)
|
||||
});
|
||||
picker.focus_handle(cx).focus(window, cx);
|
||||
Some(picker)
|
||||
}
|
||||
|
||||
if !unavailable_cloned.is_empty() {
|
||||
menu = menu.separator().header("Hidden by other filters");
|
||||
for (entry, _blocked_by) in &unavailable_cloned {
|
||||
let label: SharedString =
|
||||
format_filter_label(entry.content.as_ref(), entry.occurred_times()).into();
|
||||
menu = menu.custom_entry(
|
||||
{
|
||||
let label = label.clone();
|
||||
move |_window, cx| {
|
||||
div()
|
||||
.px_2()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child(label.clone())
|
||||
.into_any_element()
|
||||
}
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
menu
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn format_filter_label(content: Option<&SharedString>, count: usize) -> String {
|
||||
match content {
|
||||
Some(s) => format!("{s} ({count})"),
|
||||
None => format!("<null> ({count})"),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user