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 col_response = self
52            .col(|ui| {
53                let item_spacing = ui.spacing().item_spacing;
54                let gapless_rect = ui.max_rect().expand2(0.5 * item_spacing);
55                ui.painter().rect_filled(
56                    gapless_rect,
57                    egui::CornerRadius::ZERO,
58                    ui.visuals().widgets.noninteractive.bg_stroke.color,
59                );
60
61                ui.horizontal(|ui| {
62                    ui.strong(text);
63
64                    if let Some(sort_up) = sort_up {
65                        if *sort_up {
66                            ui.strong("🔻");
67                        } else {
68                            ui.strong("🔺");
69                        }
70                    }
71
72                    if previous_response.hovered && sort_up.is_none() {
73                        ui.label("🔻");
74                    }
75
76                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
77                        ui.add_space(5.0);
78
79                        let popup_id = ui.make_persistent_id(ui.id().with("filter"));
80                        let popup_open = if previous_response.secondary_clicked {
81                            egui::Popup::toggle_id(ui.ctx(), popup_id);
82                            true
83                        } else {
84                            egui::Popup::is_id_open(ui.ctx(), popup_id)
85                        };
86
87                        // Cleanup empty filters when the popup closes
88                        if !popup_open {
89                            if response.filtering.is_empty() {
90                                // Reset to a clean default if effectively empty
91                                response.filtering = Filter::default();
92                            } else if response.filtering.search.text().is_empty()
93                                && response.filtering.search.is_active()
94                            {
95                                // If search text is empty but active, deactivate it to be clean
96                                response.filtering.search.clear();
97                            }
98                        }
99
100                        // Show ellipsis if: filtering is active, hovering, or popup is open
101                        if !response.filtering.is_empty() || previous_response.hovered || popup_open
102                        {
103                            let ellipsis_response = draw_vertical_ellipsis(ui);
104
105                            let width = 150.0;
106                            egui::Popup::menu(&ellipsis_response)
107                                .id(popup_id)
108                                .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
109                                .show(|ui| {
110                                    ui.set_width(width);
111                                    set_menu_style(ui.style_mut());
112
113                                    ui.strong(t!("column-options"));
114                                    ui.separator();
115
116                                    // 1. Text Search
117                                    SearchBar::new(&t!("filter-text"))
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(&t!("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                                    let sort_desc = match sort_up {
142                                        Some(true) => format!(" {}", t!("current-ascending")),
143                                        Some(false) => format!(" {}", t!("current-descending")),
144                                        None => String::new(),
145                                    };
146                                    if ui
147                                        .button(format!("{} {sort_desc}", t!("toggle-sort")))
148                                        .clicked()
149                                    {
150                                        response.to_sort = true;
151                                    }
152                                });
153                        }
154                    });
155                });
156            })
157            .1;
158
159        if let Some(err) = halt_error {
160            return Err(err);
161        }
162
163        if col_response.contains_pointer() {
164            response.hovered = true;
165        }
166        if col_response.clicked() {
167            response.to_sort = true;
168        }
169        if col_response.secondary_clicked() {
170            response.secondary_clicked = true;
171        }
172
173        Ok(response)
174    }
175}
176
177fn draw_vertical_ellipsis(ui: &mut egui::Ui) -> egui::Response {
178    // Define size parameters
179    let text_height = ui.text_style_height(&egui::TextStyle::Body) - 3.0;
180    let dot_radius = text_height * 0.13; // Adjust based on visual preference
181    let spacing = dot_radius * 2.0;
182    let ellipsis_height = (3.0 * dot_radius).mul_add(2.0, 2.0 * spacing); // Total height of ellipsis
183
184    // Reserve space for the ellipsis
185    let (rect, response) = ui.allocate_exact_size(
186        egui::Vec2::new(dot_radius * 2.0, ellipsis_height),
187        egui::Sense::click(),
188    );
189
190    // Draw the ellipsis
191    if ui.is_rect_visible(rect) {
192        let painter = ui.painter();
193        let color = if response.hovered() {
194            ui.visuals().widgets.hovered.bg_fill
195        } else {
196            ui.visuals().widgets.inactive.bg_fill
197        };
198
199        for i in 0..3 {
200            #[allow(clippy::cast_precision_loss)]
201            let center = rect.center()
202                + egui::Vec2::new(0.0, (i as f32 - 1.0) * dot_radius.mul_add(2.0, spacing));
203            painter.circle_filled(center, dot_radius, color);
204        }
205    }
206
207    response
208}
209
210pub trait HeaderTrait<'a> {
211    fn archived_headers(
212        self,
213        data: &TableState,
214        headers: impl IntoIterator<Item = &'a str>,
215        height: f32,
216        org_colors: &[[u8; 3]],
217        user_colors: &[[u8; 3]],
218    ) -> Result<(Vec<ColResponse>, Table<'a>), TableError>;
219}
220
221impl<'a> HeaderTrait<'a> for TableBuilder<'a> {
222    fn archived_headers(
223        self,
224        data: &TableState,
225        headers: impl IntoIterator<Item = &'a str>,
226        height: f32,
227        org_colors: &[[u8; 3]],
228        user_colors: &[[u8; 3]],
229    ) -> Result<(Vec<ColResponse>, Table<'a>), TableError> {
230        let headers = headers.into_iter();
231        let headers_count = headers.size_hint().0;
232        let mut messages = Vec::with_capacity(headers_count);
233
234        let default_response = ColResponse::default();
235        let mut halt_error: Option<TableError> = None;
236        let table = self.header(height, |mut header| {
237            for (i, title) in headers.enumerate() {
238                // Direct reference extraction; completely allocation-free
239                let (previous_response, sort_up) = data
240                    .columns
241                    .get(i)
242                    .map_or((&default_response, None), |col| {
243                        (&col.response, col.sort_up)
244                    });
245
246                if let Ok(message) =
247                    header.header_cell(title, &sort_up, previous_response, org_colors, user_colors)
248                {
249                    messages.push(message);
250                } else {
251                    halt_error = Some(TableError::CorruptedState);
252                    return;
253                }
254            }
255        });
256
257        halt_error.map_or(Ok((messages, table)), Err)
258    }
259}
260
261pub const fn set_menu_style(style: &mut egui::Style) {
262    style.wrap_mode = Some(egui::TextWrapMode::Extend);
263
264    style.spacing.button_padding = egui::vec2(2.0, 0.0);
265    style.visuals.widgets.active.bg_stroke = egui::Stroke::NONE;
266    style.visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE;
267    style.visuals.widgets.inactive.weak_bg_fill = egui::Color32::TRANSPARENT;
268    style.visuals.widgets.inactive.bg_stroke = egui::Stroke::NONE;
269
270    style.visuals.selection.bg_fill = egui::Color32::from_gray(50);
271    style.visuals.selection.stroke.color = egui::Color32::from_rgb(86, 92, 128);
272}