Skip to main content

egui_table_kit/
header.rs

1use egui_extras::{Table, TableBuilder, TableRow};
2
3use super::{
4    error::TableError,
5    filter::{Filter, highlight::HighlightFilter, search::SearchBar},
6    state::TableState,
7};
8
9#[derive(Clone, Debug, Default)]
10pub struct ColumnState {
11    pub response: ColResponse,
12    pub sort_up: Option<bool>,
13}
14
15#[derive(Clone, Debug, Default)]
16pub struct ColResponse {
17    pub to_sort: bool,
18    pub hovered: bool,
19
20    pub filtering: Filter,
21    pub secondary_clicked: bool,
22}
23
24pub trait TableHeaderRowExt {
25    fn header_cell(
26        &mut self,
27        text: &str,
28        sort_up: &Option<bool>,
29        previous_response: &ColResponse,
30        org_colors: &[[u8; 3]],
31        user_colors: &[[u8; 3]],
32    ) -> Result<ColResponse, TableError>;
33}
34
35impl TableHeaderRowExt for TableRow<'_, '_> {
36    #[allow(clippy::too_many_lines)]
37    fn header_cell(
38        &mut self,
39        text: &str,
40        sort_up: &Option<bool>,
41        previous_response: &ColResponse,
42        org_colors: &[[u8; 3]],
43        user_colors: &[[u8; 3]],
44    ) -> Result<ColResponse, TableError> {
45        let mut response = ColResponse {
46            filtering: previous_response.filtering.clone(),
47            ..Default::default()
48        };
49        let mut halt_error: Option<TableError> = None;
50        let col_response = self
51            .col(|ui| {
52                let item_spacing = ui.spacing().item_spacing;
53                let gapless_rect = ui.max_rect().expand2(0.5 * item_spacing);
54                ui.painter().rect_filled(
55                    gapless_rect,
56                    egui::CornerRadius::ZERO,
57                    ui.visuals().widgets.noninteractive.bg_stroke.color,
58                );
59
60                ui.horizontal(|ui| {
61                    ui.strong(text);
62
63                    if let Some(sort_up) = sort_up {
64                        if *sort_up {
65                            ui.strong("🔻");
66                        } else {
67                            ui.strong("🔺");
68                        }
69                    }
70
71                    if previous_response.hovered && sort_up.is_none() {
72                        ui.label("🔻");
73                    }
74
75                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
76                        ui.add_space(5.0);
77
78                        let popup_id = ui.make_persistent_id(ui.id().with("filter"));
79                        let popup_open = if previous_response.secondary_clicked {
80                            egui::Popup::toggle_id(ui.ctx(), popup_id);
81                            true
82                        } else {
83                            egui::Popup::is_id_open(ui.ctx(), popup_id)
84                        };
85
86                        // Cleanup empty filters when the popup closes
87                        if !popup_open {
88                            if response.filtering.is_empty() {
89                                // Reset to a clean default if effectively empty
90                                response.filtering = Filter::default();
91                            } else if response.filtering.search.text().is_empty()
92                                && response.filtering.search.is_active()
93                            {
94                                // If search text is empty but active, deactivate it to be clean
95                                response.filtering.search.clear();
96                            }
97                        }
98
99                        // Show ellipsis if: filtering is active, hovering, or popup is open
100                        if !response.filtering.is_empty() || previous_response.hovered || popup_open
101                        {
102                            let ellipsis_response = draw_vertical_ellipsis(ui);
103
104                            let width = 150.0;
105                            egui::Popup::menu(&ellipsis_response)
106                                .id(popup_id)
107                                .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
108                                .show(|ui| {
109                                    ui.set_width(width);
110                                    set_menu_style(ui.style_mut());
111
112                                    ui.strong("Column Options");
113                                    ui.separator();
114
115                                    // 1. Text Search
116                                    // SearchBar::ui handles the internal mutation of the Search struct
117                                    SearchBar::new("Text Filter")
118                                        .ui(ui, &mut response.filtering.search);
119
120                                    // 2. Highlight Filter (Render ONLY if color palettes are provided)
121                                    if (!org_colors.is_empty() || !user_colors.is_empty())
122                                        && HighlightFilter::new("Highlight Filter")
123                                            .ui(
124                                                ui,
125                                                &mut response.filtering.highlight,
126                                                org_colors,
127                                                user_colors,
128                                            )
129                                            .is_err()
130                                    {
131                                        halt_error = Some(TableError::CorruptedState);
132                                    }
133
134                                    // Auto-cleanup while editing:
135                                    // If both are cleared by the user in the UI, we might want to know,
136                                    // but checking is_empty() at the start of next frame/close is safer.
137
138                                    ui.separator();
139
140                                    // 3. Sorting Options
141                                    if ui
142                                        .button(format!(
143                                            "Toggle Sort {}",
144                                            match sort_up {
145                                                Some(true) => "(Ascending)",
146                                                Some(false) => "(Descending)",
147                                                None => "",
148                                            }
149                                        ))
150                                        .clicked()
151                                    {
152                                        response.to_sort = true;
153                                    }
154                                });
155                        }
156                    });
157                });
158            })
159            .1;
160
161        if let Some(err) = halt_error {
162            return Err(err);
163        }
164
165        if col_response.contains_pointer() {
166            response.hovered = true;
167        }
168        if col_response.clicked() {
169            response.to_sort = true;
170        }
171        if col_response.secondary_clicked() {
172            response.secondary_clicked = true;
173        }
174
175        Ok(response)
176    }
177}
178
179fn draw_vertical_ellipsis(ui: &mut egui::Ui) -> egui::Response {
180    // Define size parameters
181    let text_height = ui.text_style_height(&egui::TextStyle::Body) - 3.0;
182    let dot_radius = text_height * 0.13; // Adjust based on visual preference
183    let spacing = dot_radius * 2.0;
184    let ellipsis_height = (3.0 * dot_radius).mul_add(2.0, 2.0 * spacing); // Total height of ellipsis
185
186    // Reserve space for the ellipsis
187    let (rect, response) = ui.allocate_exact_size(
188        egui::Vec2::new(dot_radius * 2.0, ellipsis_height),
189        egui::Sense::click(),
190    );
191
192    // Draw the ellipsis
193    if ui.is_rect_visible(rect) {
194        let painter = ui.painter();
195        let color = if response.hovered() {
196            ui.visuals().widgets.hovered.bg_fill
197        } else {
198            ui.visuals().widgets.inactive.bg_fill
199        };
200
201        for i in 0..3 {
202            #[allow(clippy::cast_precision_loss)]
203            let center = rect.center()
204                + egui::Vec2::new(0.0, (i as f32 - 1.0) * dot_radius.mul_add(2.0, spacing));
205            painter.circle_filled(center, dot_radius, color);
206        }
207    }
208
209    response
210}
211
212pub trait HeaderTrait<'a> {
213    fn archived_headers(
214        self,
215        data: &TableState,
216        headers: impl IntoIterator<Item = &'a str>,
217        height: f32,
218        org_colors: &[[u8; 3]],
219        user_colors: &[[u8; 3]],
220    ) -> Result<(Vec<ColResponse>, Table<'a>), TableError>;
221}
222
223impl<'a> HeaderTrait<'a> for TableBuilder<'a> {
224    fn archived_headers(
225        self,
226        data: &TableState,
227        headers: impl IntoIterator<Item = &'a str>,
228        height: f32,
229        org_colors: &[[u8; 3]],
230        user_colors: &[[u8; 3]],
231    ) -> Result<(Vec<ColResponse>, Table<'a>), TableError> {
232        let headers = headers.into_iter();
233        let headers_count = headers.size_hint().0;
234        let mut messages = Vec::with_capacity(headers_count);
235
236        let default_response = ColResponse::default();
237        let mut halt_error: Option<TableError> = None;
238        let table = self.header(height, |mut header| {
239            for (i, title) in headers.enumerate() {
240                // Direct reference extraction; completely allocation-free
241                let (previous_response, sort_up) = data
242                    .columns
243                    .get(i)
244                    .map_or((&default_response, None), |col| {
245                        (&col.response, col.sort_up)
246                    });
247
248                if let Ok(message) =
249                    header.header_cell(title, &sort_up, previous_response, org_colors, user_colors)
250                {
251                    messages.push(message);
252                } else {
253                    halt_error = Some(TableError::CorruptedState);
254                    return;
255                }
256            }
257        });
258
259        halt_error.map_or(Ok((messages, table)), Err)
260    }
261}
262
263pub const fn set_menu_style(style: &mut egui::Style) {
264    style.wrap_mode = Some(egui::TextWrapMode::Extend);
265
266    style.spacing.button_padding = egui::vec2(2.0, 0.0);
267    style.visuals.widgets.active.bg_stroke = egui::Stroke::NONE;
268    style.visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE;
269    style.visuals.widgets.inactive.weak_bg_fill = egui::Color32::TRANSPARENT;
270    style.visuals.widgets.inactive.bg_stroke = egui::Stroke::NONE;
271
272    style.visuals.selection.bg_fill = egui::Color32::from_gray(50);
273    style.visuals.selection.stroke.color = egui::Color32::from_rgb(86, 92, 128);
274}