use gpui::ElementId; use ui::{ ContextMenu, GradientFade, IconButton, IconName, IconSize, PopoverMenu, Tooltip, prelude::*, }; use crate::{ CsvPreviewView, settings::FilterSortOrder, table_data_engine::{ filtering_by_column::{FilterEntry, FilterEntryState}, sorting_by_column::{AppliedSorting, SortDirection}, }, types::AnyColumn, }; 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( &self, header_text: SharedString, cx: &mut Context<'_, CsvPreviewView>, 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!("csv-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( div() .flex_1() .min_w_0() .overflow_hidden() .whitespace_nowrap() .child(header_text), ) .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<'_, CsvPreviewView>, col_idx: AnyColumn, ) -> Button { let sort_btn = 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) .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 => { // 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 { 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 { let has_active_filters = self.engine.has_active_filters(col); 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::Filter, ) .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 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) } }) } fn create_filter_menu( window: &mut ui::Window, cx: &mut ui::App, view_entity: gpui::Entity, col: AnyColumn, column_filters: &[(FilterEntry, FilterEntryState)], has_active_filters: bool, sort_order: FilterSortOrder, ) -> gpui::Entity { 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 = 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(); }); } }); } 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!(" ({count})"), } }