Files
zed/crates/tabular_data_preview/src/renderer/table_header.rs
T
Oleksandr Kholiavko 134e04444c tabular_data_preview: Revive filter funnel icon (#63266)
# Objective

Originally, `IconName::Filter` was a funnel glyph and
`IconName::Sliders` was used for settings/controls.

In #60937 (commit `1a06fd4f2b`), `filter.svg` was replaced with the
sliders glyph, and `sliders.svg` / `Sliders` enum variants were deleted.
Consequently, `IconName::Filter` has become a sliders-shaped icon used
across 8+ surfaces in Zed (git_ui, search, debugger_ui, keymap_editor,
language_tools, quick_action_bar, editor view settings).

When moving tabular data preview settings to follow the editor's
convention (using `IconName::Filter` sliders for table view settings),
column filter buttons in the table header were left using the same
sliders icon instead of a traditional funnel.

Repurposing `IconName::Filter` back to a funnel would break all other UI
surfaces across Zed.

## Solution

- Restored the funnel icon under a new dedicated
`IconName::FilterFunnel` variant (`assets/icons/filter_funnel.svg` and
`crates/icons/src/icons.rs`).
- Updated `tabular_data_preview` column filter trigger buttons
(`table_header.rs`) to use `IconName::FilterFunnel`.
- Left all existing `IconName::Filter` (sliders) usages across the app
untouched.

Before:
<img width="363" height="156" alt="image"
src="https://github.com/user-attachments/assets/84ac5f87-c0f7-4de9-94ea-e4040e3b4a4a"
/>

After:
<img width="361" height="151" alt="image"
src="https://github.com/user-attachments/assets/38514f3a-0710-4273-b227-4623bf78410b"
/>

## Testing

- `cargo check -p icons -p tabular_data_preview` and `./script/clippy`
build clean.
- `cargo test -p tabular_data_preview --lib` passes.
- Verified column filter button renders the funnel icon in tabular data
preview and highlights correctly when filters are active.

## 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

Release Notes:

- Replace column filter icon in tabular data preview with filter funnel
2026-08-31 11:43:50 +00:00

714 lines
25 KiB
Rust

use std::{
borrow::Cow,
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::{
Color, GradientFade, HighlightedLabel, Icon, IconButton, IconName, IconSize, Label, LabelSize,
ListItem, ListItemSpacing, PopoverMenu, Tooltip, prelude::*, utils::replace_control_characters,
};
use crate::{
TabularDataPreviewPane,
renderer::table_cell::with_copy_on_right_click,
settings::FilterSortOrder,
table_data_engine::{
filtering_by_column::{FilterEntry, FilterEntryState},
sorting_by_column::{AppliedSorting, SortDirection},
},
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<TabularDataPreviewPane>,
/// 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<TabularDataPreviewPane>,
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, bool)> = 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,
is_applied,
} => hidden.push((entry.clone(), *blocked_by, *is_applied)),
}
}
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, is_applied)| ColumnFilterRow {
entry,
is_applied,
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,
is_applied,
} => {
row.is_applied = is_applied;
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 {
"table 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();
}
/// A hidden (blocked-by-another-filter) row can still be selected if it's currently
/// applied, so the user can uncheck it from the popover; a hidden-and-unapplied row
/// can't be newly selected, since doing so would only add a filter guaranteed to
/// leave zero rows.
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, .. }) => {
let row = &self.rows[*row_index];
row.hidden_by.is_none() || row.is_applied
}
_ => 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() && !self.rows[row_index].is_applied {
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();
// Hidden-and-unapplied rows are disabled (can't newly select a value that
// would zero out the table); hidden-and-applied rows stay interactive so the
// user can uncheck them, same as a normal available row, just muted to signal
// it's currently blocked by another filter.
let is_hidden = row.hidden_by.is_some();
let color = if is_hidden {
Color::Disabled
} else {
Color::Muted
};
Some(
ListItem::new(("table-filter-value", ix))
.disabled(is_hidden && !row.is_applied)
.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(
if is_hidden {
Color::Disabled
} else {
Color::Accent
},
)),
)
.child(label.color(color))
.end_slot(Label::new(count_text).size(LabelSize::Small).color(color))
.tooltip(Tooltip::text(value_text))
.into_any_element(),
)
}
}
}
fn render_footer(
&self,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<AnyElement> {
if !self.view.read(cx).engine.has_active_filters(self.col) {
return None;
}
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();
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("table-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 TabularDataPreviewPane {
/// 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(
&self,
header_text: SharedString,
cx: &mut Context<'_, TabularDataPreviewPane>,
col_idx: AnyColumn,
) -> AnyElement {
let has_active_filter = self.engine.has_active_filters(col_idx);
let has_active_sort = self
.engine
.applied_sorting
.is_some_and(|o| o.col_idx == col_idx);
let always_show_buttons = has_active_filter || has_active_sort;
let group_name = SharedString::from(format!("table-col-header-{}", col_idx.get()));
let colors = cx.theme().colors();
let base_bg = colors.editor_background;
let grad_width_hovered = px(100.);
let grad_width = if always_show_buttons {
grad_width_hovered
} else {
px(20.)
};
h_flex()
.group(group_name.clone())
.relative()
.overflow_hidden()
.w_full()
.items_center()
.font_buffer(cx)
.text_buffer(cx)
.child({
let header_text_cell = div()
.id(ElementId::NamedInteger(
"table-col-header-text".into(),
col_idx.get() as u64,
))
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap();
// Column names come from the file being previewed, so they may
// hold control characters. Show stand-ins for those, but keep
// the real name for copying.
let displayed_header = match replace_control_characters(&header_text) {
Cow::Borrowed(_) => header_text.clone(),
Cow::Owned(replaced) => SharedString::from(replaced),
};
with_copy_on_right_click(
header_text_cell,
header_text,
"Right click to copy column name",
)
.child(displayed_header)
})
.child(
GradientFade::new(base_bg, base_bg, base_bg)
.width(grad_width)
.width_hovered(grad_width_hovered)
.right(px(0.))
.gradient_stop(0.8)
.group_name(group_name.clone()),
)
.child(
h_flex()
.absolute()
.right_0()
.top_0()
.h_full()
.items_center()
.gap_1()
.when(!always_show_buttons, |this| {
this.visible_on_hover(group_name)
})
.child(self.create_filter_button(cx, col_idx))
.child(self.create_sort_button(cx, col_idx)),
)
.into_any_element()
}
fn create_sort_button(
&self,
cx: &mut Context<'_, TabularDataPreviewPane>,
col_idx: AnyColumn,
) -> Button {
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 => "↑",
},
_ => "↕",
},
)
.size(ButtonSize::Compact)
.style(
if self
.engine
.applied_sorting
.is_some_and(|o| o.col_idx == col_idx)
{
ButtonStyle::Filled
} else {
ButtonStyle::Subtle
},
)
.tooltip(Tooltip::text(match self.engine.applied_sorting {
Some(ordering) if ordering.col_idx == col_idx => match ordering.direction {
SortDirection::Asc => "Sorted A-Z. Click to sort Z-A",
SortDirection::Desc => "Sorted Z-A. Click to disable sorting",
},
_ => "Not sorted. Click to sort A-Z",
}))
.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 => match ordering.direction {
SortDirection::Asc => Some(AppliedSorting {
col_idx,
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();
}))
}
fn create_filter_button(
&self,
cx: &mut Context<'_, TabularDataPreviewPane>,
col: AnyColumn,
) -> 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(),
col.get() as u64,
))
.trigger_with_tooltip(
IconButton::new(
ElementId::NamedInteger("filter-button".into(), col.get() as u64),
IconName::FilterFunnel,
)
.icon_size(IconSize::Small)
.style(if has_active_filters {
ButtonStyle::Filled
} else {
ButtonStyle::Subtle
})
.toggle_state(has_active_filters),
Tooltip::text(if has_active_filters {
"Column has active filters. Click to manage"
} else {
"No filters applied. Click to add filters"
}),
)
.menu({
let view_entity = cx.entity();
move |window, cx| {
let view = view_entity.read(cx);
let column_filters = match view.engine.get_filters_for_column(col) {
Ok(filters) => filters,
Err(err) => {
log::error!("Failed to get filters for column: {err}");
return None;
}
};
let view_entity = view_entity.clone();
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)
}
})
}
}