Skip to main content

egui_table_kit/
header.rs

1//! Layout and options structures for column header interaction overlays.
2
3use fluent_zero::t;
4
5use super::{
6    error::TableError,
7    filter::{Filter, highlight::HighlightFilter, search::SearchBar},
8};
9
10/// Configurable anchor positions for the column options context menus.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12pub enum HeaderMenuAnchor {
13    /// Anchors the popup menu cleanly below the header cell boundaries.
14    Cell,
15    /// Anchors the popup menu directly at the cursor's exact click position.
16    #[default]
17    Cursor,
18}
19
20/// Tracks structural and interactive view states inside individual columns.
21#[derive(Clone, Debug, Default)]
22pub struct ColumnState {
23    pub response: ColResponse,
24    pub sort_up: Option<bool>,
25}
26
27/// Dynamic event triggers collected during header cell render steps.
28#[derive(Clone, Debug, Default)]
29pub struct ColResponse {
30    pub to_sort: bool,
31    pub hovered: bool,
32    pub filtering: Filter,
33    pub secondary_clicked: bool,
34}
35
36/// Applies styling adjustments to the column options popups.
37pub const fn set_menu_style(style: &mut egui::Style) {
38    style.wrap_mode = Some(egui::TextWrapMode::Extend);
39
40    style.spacing.button_padding = egui::vec2(2.0, 0.0);
41    style.visuals.widgets.active.bg_stroke = egui::Stroke::NONE;
42    style.visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE;
43    style.visuals.widgets.inactive.weak_bg_fill = egui::Color32::TRANSPARENT;
44    style.visuals.widgets.inactive.bg_stroke = egui::Stroke::NONE;
45
46    style.visuals.selection.bg_fill = egui::Color32::from_gray(50);
47    style.visuals.selection.stroke.color = egui::Color32::from_rgb(86, 92, 128);
48}
49
50/// Interactive header cell renderer.
51pub fn show_header_cell_contents(
52    ui: &mut egui::Ui,
53    text: &str,
54    sort_up: &Option<bool>,
55    previous_response: &ColResponse,
56    org_colors: &[[u8; 3]],
57    user_colors: &[[u8; 3]],
58    anchor_mode: HeaderMenuAnchor,
59) -> Result<ColResponse, TableError> {
60    let mut response = ColResponse {
61        filtering: previous_response.filtering.clone(),
62        ..Default::default()
63    };
64    let mut halt_error = None;
65    let is_hovered = ui.rect_contains_pointer(ui.max_rect());
66
67    let item_spacing = ui.spacing().item_spacing;
68    let gapless_rect = ui.max_rect().expand2(0.5 * item_spacing);
69
70    // Apply background fill for the header cell
71    let bg_color = ui.visuals().widgets.noninteractive.weak_bg_fill;
72    ui.painter()
73        .rect_filled(gapless_rect, egui::CornerRadius::ZERO, bg_color);
74
75    // Use the hierarchical ID instead of a thread-local counter.
76    // This is stable and collision-free across sizing/painting passes.
77    let unique_id = ui.id();
78
79    // Passive hover sensor to anchor the popup correctly
80    let cell_interact = ui.interact(
81        gapless_rect,
82        unique_id.with("header_interact"),
83        egui::Sense::hover(),
84    );
85
86    let popup_id = ui.make_persistent_id(unique_id.with("filter"));
87    let anchor_pos_id = unique_id.with("popup_anchor_pos");
88
89    // Retrieve or create a stable click coordinate anchor
90    let mut click_anchor = ui.data_mut(|d| d.get_temp::<egui::Pos2>(anchor_pos_id));
91
92    // Direct pointer tracking for right-clicks to guarantee context menu activation
93    let is_cell_hovered = ui.rect_contains_pointer(gapless_rect);
94    let right_clicked = is_cell_hovered && ui.input(|i| i.pointer.secondary_clicked());
95    if right_clicked {
96        egui::Popup::toggle_id(ui.ctx(), popup_id);
97    }
98
99    let popup_open = egui::Popup::is_id_open(ui.ctx(), popup_id);
100    let mut ellipsis_clicked = false;
101
102    // Determine Overlay Dimensions & Active States
103    let has_filter = !response.filtering.is_empty();
104    let is_sorted = sort_up.is_some();
105    let show_ellipsis = has_filter || is_hovered || popup_open;
106    let show_sort = is_sorted || is_hovered;
107
108    let right_padding = 8.0f32;
109    let mut solid_width = 0.0f32;
110    let mut ellipsis_center_x = None;
111
112    let cell_width = gapless_rect.width();
113    let right_x = gapless_rect.max.x;
114
115    // Only reserve solid width for the ellipsis on the far right
116    if !ui.is_sizing_pass() && cell_width > 20.0 && show_ellipsis {
117        let current_offset = right_padding;
118        ellipsis_center_x = Some(right_x - current_offset - 6.0); // 12px width
119        solid_width = current_offset + 12.0;
120    }
121
122    ui.horizontal(|ui| {
123        ui.add_space(8.0);
124
125        if ui.is_sizing_pass() {
126            ui.add(
127                egui::Label::new(egui::RichText::new(text).strong())
128                    .selectable(false)
129                    .wrap_mode(ui.wrap_mode()),
130            );
131            if show_sort {
132                let sort_text = match sort_up {
133                    Some(false) => "🔺",
134                    Some(true) | None => "🔻",
135                };
136                let font_id = egui::FontId::proportional(11.0);
137                ui.add(
138                    egui::Label::new(egui::RichText::new(sort_text).font(font_id))
139                        .selectable(false),
140                );
141            }
142            ui.add_space(8.0);
143        } else {
144            // Allocate the text container with a restricted layout width.
145            // Left padding is 8.0, safety gap is 4.0. Total non-text offset from left is 12.0.
146            let max_container_width = (cell_width - solid_width - 12.0).max(0.0);
147            ui.allocate_ui(
148                egui::vec2(max_container_width, ui.available_height()),
149                |ui| {
150                    ui.horizontal(|ui| {
151                        ui.spacing_mut().item_spacing.x = 6.0;
152
153                        let sort_width = if show_sort { 14.0 } else { 0.0 };
154                        let max_text_width = (max_container_width - sort_width).max(0.0);
155
156                        ui.allocate_ui(egui::vec2(max_text_width, ui.available_height()), |ui| {
157                            ui.add(
158                                egui::Label::new(egui::RichText::new(text).strong())
159                                    .selectable(false)
160                                    .wrap_mode(egui::TextWrapMode::Truncate),
161                            );
162                        });
163
164                        if show_sort {
165                            let sort_text = match sort_up {
166                                Some(false) => "🔺",
167                                Some(true) | None => "🔻",
168                            };
169                            let sort_color = if sort_up.is_some() {
170                                ui.visuals().widgets.active.text_color()
171                            } else {
172                                ui.visuals()
173                                    .widgets
174                                    .inactive
175                                    .text_color()
176                                    .linear_multiply(0.4)
177                            };
178
179                            let font_id = egui::FontId::proportional(11.0);
180                            let sort_response = ui.add(
181                                egui::Label::new(
182                                    egui::RichText::new(sort_text)
183                                        .font(font_id)
184                                        .color(sort_color),
185                                )
186                                .selectable(false),
187                            );
188
189                            // Keep the sorting click interaction alive for the inline indicator icon
190                            let sort_rect = sort_response.rect;
191                            let sort_interact_response = ui.interact(
192                                sort_rect,
193                                unique_id.with("sort_interact"),
194                                egui::Sense::click(),
195                            );
196                            if sort_interact_response.clicked() {
197                                response.to_sort = true;
198                            }
199                        }
200                    });
201                },
202            );
203        }
204    });
205
206    if !ui.is_sizing_pass() && show_ellipsis && cell_width > 20.0 {
207        let center_y = gapless_rect.center().y;
208
209        // Render & Interact with Vertical Ellipsis Button
210        if let Some(cx) = ellipsis_center_x {
211            let ellipsis_rect =
212                egui::Rect::from_center_size(egui::pos2(cx, center_y), egui::vec2(12.0, 18.0));
213
214            let ellipsis_response = ui.interact(
215                ellipsis_rect,
216                unique_id.with("ellipsis_interact"),
217                egui::Sense::click(),
218            );
219
220            if ellipsis_response.clicked() {
221                ellipsis_clicked = true;
222            }
223
224            // Paint vertical ellipsis dots
225            let center = ellipsis_rect.center();
226            let dot_radius = 1.3;
227            let spacing = 3.5;
228            let dot_color = if ellipsis_response.hovered() {
229                ui.visuals().widgets.hovered.text_color()
230            } else {
231                ui.visuals()
232                    .widgets
233                    .inactive
234                    .text_color()
235                    .linear_multiply(0.7)
236            };
237            for i in -1..=1 {
238                #[allow(clippy::cast_precision_loss)]
239                let dot_center = center + egui::vec2(0.0, (i as f32) * spacing);
240                ui.painter()
241                    .circle_filled(dot_center, dot_radius, dot_color);
242            }
243        }
244    }
245
246    // Toggle popup if ellipsis was left-clicked
247    if ellipsis_clicked {
248        egui::Popup::toggle_id(ui.ctx(), popup_id);
249    }
250
251    // Capture precise pointer location upon activation
252    if (right_clicked || ellipsis_clicked)
253        && click_anchor.is_none()
254        && let Some(pointer_pos) = ui.ctx().pointer_latest_pos()
255    {
256        ui.data_mut(|d| d.insert_temp(anchor_pos_id, pointer_pos));
257        click_anchor = Some(pointer_pos);
258    }
259
260    // Direct, collision-free left-click sorting
261    if is_cell_hovered && ui.input(|i| i.pointer.primary_clicked()) && !ellipsis_clicked {
262        response.to_sort = true;
263    }
264
265    // Resolve target response layout reference to attach the menu
266    let menu_anchor_response = match anchor_mode {
267        HeaderMenuAnchor::Cursor if let Some(pos) = click_anchor => {
268            let anchor_rect = egui::Rect::from_center_size(pos, egui::Vec2::splat(1.0));
269            ui.interact(
270                anchor_rect,
271                unique_id.with("dummy_popup_anchor"),
272                egui::Sense::hover(),
273            )
274        }
275        _ => cell_interact,
276    };
277
278    // Render the options popup below the resolved anchor
279    egui::Popup::menu(&menu_anchor_response)
280        .id(popup_id)
281        .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
282        .show(|ui| {
283            ui.set_width(150.0);
284            set_menu_style(ui.style_mut());
285
286            ui.strong(t!("column-options"));
287            ui.separator();
288
289            SearchBar::new(&t!("filter-text")).ui(ui, &mut response.filtering.search);
290
291            if (!org_colors.is_empty() || !user_colors.is_empty())
292                && HighlightFilter::new(&t!("new-highlight-filter"))
293                    .ui(
294                        ui,
295                        &mut response.filtering.highlight,
296                        org_colors,
297                        user_colors,
298                    )
299                    .is_err()
300            {
301                halt_error = Some(TableError::CorruptedState);
302            }
303
304            ui.separator();
305
306            let sort_desc = match sort_up {
307                Some(true) => format!(" {}", t!("current-ascending")),
308                Some(false) => format!(" {}", t!("current-descending")),
309                None => String::new(),
310            };
311            if ui
312                .button(format!("{} {sort_desc}", t!("toggle-sort")))
313                .clicked()
314            {
315                response.to_sort = true;
316                ui.close_kind(egui::UiKind::Menu);
317            }
318        });
319
320    // Cleanup coordinates once the popup is closed
321    if !popup_open {
322        ui.data_mut(|d| {
323            d.remove::<egui::Pos2>(anchor_pos_id);
324        });
325    }
326
327    if let Some(err) = halt_error {
328        return Err(err);
329    }
330
331    if is_hovered {
332        response.hovered = true;
333    }
334    if ellipsis_clicked {
335        response.secondary_clicked = true;
336    }
337
338    Ok(response)
339}