Skip to main content

egui_table_kit/
header.rs

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