Skip to main content

egui_table_kit/
delegate.rs

1use egui::{Color32, Margin, Response, Sense, Ui};
2
3use super::{
4    error::TableError,
5    filter::highlight::select_color,
6    header::{ColResponse, HeaderMenuAnchor, show_header_cell_contents},
7    layout::{CellInfo, HeaderCellInfo, PrefetchInfo, TableDelegate},
8    operations::{BorrowedRow, Row, TableProvider},
9    state::TableState,
10};
11
12/// Shared callback definition used to render custom interactive cellular components.
13pub type CustomCellCallback<'a> =
14    dyn FnMut(&mut Ui, &CellInfo, &dyn Row, Color32) -> Option<Response> + 'a;
15
16/// The central delegate translating kit properties to the underlying `egui_table` layout.
17pub struct TableKitDelegate<'a> {
18    pub provider: &'a dyn TableProvider,
19    pub state: &'a mut TableState,
20    pub org_colors: &'a [[u8; 3]],
21    pub user_colors: &'a [[u8; 3]],
22    pub collected_responses: &'a mut Vec<ColResponse>,
23    pub halt_error: &'a mut Option<TableError>,
24    pub custom_cell_ui: Option<Box<CustomCellCallback<'a>>>,
25    pub item_clicked: &'a mut Option<usize>,
26    pub secondary_clicked: &'a mut Option<usize>,
27    pub is_new_pass: bool,
28
29    // Configurations
30    pub cell_padding: Margin,
31    pub highlight_entire_row: bool,
32    pub hovered_row: Option<u64>,
33    pub header_menu_anchor: HeaderMenuAnchor,
34    pub row_height: f32,
35    pub header_bg_color: Option<Color32>,
36
37    pub striped: bool,
38    pub striping_color: Option<Color32>,
39    pub hover_color: Option<Color32>,
40}
41
42impl<'a> TableKitDelegate<'a> {
43    /// Creates a new delegate instance with robust visual defaults.
44    pub fn new(
45        provider: &'a dyn TableProvider,
46        state: &'a mut TableState,
47        org_colors: &'a [[u8; 3]],
48        user_colors: &'a [[u8; 3]],
49        collected_responses: &'a mut Vec<ColResponse>,
50        halt_error: &'a mut Option<TableError>,
51        custom_cell_ui: Option<Box<CustomCellCallback<'a>>>,
52        item_clicked: &'a mut Option<usize>,
53        secondary_clicked: &'a mut Option<usize>,
54    ) -> Self {
55        Self {
56            provider,
57            state,
58            org_colors,
59            user_colors,
60            collected_responses,
61            halt_error,
62            custom_cell_ui,
63            item_clicked,
64            secondary_clicked,
65            is_new_pass: true,
66            cell_padding: Margin::symmetric(8, 0),
67            highlight_entire_row: true,
68            hovered_row: None,
69            header_menu_anchor: HeaderMenuAnchor::Cursor,
70            row_height: 16.0,
71            header_bg_color: None,
72            striped: false,
73            striping_color: None,
74            hover_color: None,
75        }
76    }
77}
78
79impl TableDelegate for TableKitDelegate<'_> {
80    fn default_row_height(&self) -> f32 {
81        self.row_height
82    }
83
84    fn prepare(&mut self, _info: &PrefetchInfo) {
85        if self.is_new_pass {
86            self.is_new_pass = false;
87            self.collected_responses.clear();
88            self.hovered_row = None;
89        }
90    }
91
92    fn header_cell_ui(&mut self, ui: &mut Ui, cell: &HeaderCellInfo) {
93        let col_idx = cell.col_range.start;
94        let title = self.provider.header(col_idx).unwrap_or_default();
95
96        let default_response = ColResponse::default();
97        let (previous_response, sort_up) = self
98            .state
99            .columns
100            .get(col_idx)
101            .map_or((&default_response, None), |col| {
102                (&col.response, col.sort_up)
103            });
104
105        // Apply custom header background color if configured
106        let bg_color = self.header_bg_color.unwrap_or_else(|| {
107            if ui.visuals().dark_mode {
108                Color32::from_gray(38)
109            } else {
110                Color32::from_gray(230)
111            }
112        });
113        ui.visuals_mut().widgets.noninteractive.weak_bg_fill = bg_color;
114
115        // Isolate ID namespace per column to prevent sort/ellipsis widget ID collisions
116        ui.push_id(col_idx, |ui| {
117            match show_header_cell_contents(
118                ui,
119                title.as_ref(),
120                &sort_up,
121                previous_response,
122                self.org_colors,
123                self.user_colors,
124                self.header_menu_anchor,
125            ) {
126                Ok(response) => {
127                    if col_idx >= self.collected_responses.len() {
128                        self.collected_responses
129                            .resize_with(col_idx + 1, ColResponse::default);
130                    }
131                    self.collected_responses[col_idx] = response;
132                }
133                Err(e) => {
134                    *self.halt_error = Some(e);
135                }
136            }
137        });
138    }
139
140    fn cell_ui(&mut self, ui: &mut Ui, cell: &CellInfo) {
141        let current_visible_idx = cell.row_nr as usize;
142        let Some(&row_idx) = self.state.active_rows.get(current_visible_idx) else {
143            return;
144        };
145
146        let is_selected = self.state.selected_rows.contains(row_idx as u32);
147        let highlight = self.state.highlights.get_usize(row_idx);
148
149        let item_spacing = ui.spacing().item_spacing;
150        let cell_rect = ui.max_rect().expand2(0.5 * item_spacing);
151
152        // Read the hover state computed by the layout pass
153        let row_hovered = if self.highlight_entire_row {
154            cell.row_hovered
155        } else {
156            ui.rect_contains_pointer(cell_rect)
157        };
158
159        // Resolve background highlight color
160        let highlight_rgb = if let Some(color_idx) = highlight {
161            select_color(color_idx, self.org_colors, self.user_colors).ok()
162        } else {
163            None
164        };
165
166        // Paint background layers
167        if is_selected {
168            ui.painter().rect_filled(
169                cell_rect,
170                egui::CornerRadius::ZERO,
171                ui.visuals().selection.bg_fill,
172            );
173        } else {
174            // 1. Base layer
175            let base_color = if let Some(rgb) = highlight_rgb {
176                Some(Color32::from_rgb(rgb[0], rgb[1], rgb[2]))
177            } else if self.striped && cell.row_nr % 2 == 1 {
178                Some(
179                    self.striping_color
180                        .unwrap_or_else(|| ui.visuals().faint_bg_color),
181                )
182            } else {
183                None
184            };
185
186            if let Some(color) = base_color {
187                ui.painter()
188                    .rect_filled(cell_rect, egui::CornerRadius::ZERO, color);
189            }
190
191            // 2. Hover layer
192            if row_hovered {
193                let hover_fill = self
194                    .hover_color
195                    .unwrap_or_else(|| ui.visuals().widgets.hovered.weak_bg_fill);
196
197                ui.painter()
198                    .rect_filled(cell_rect, egui::CornerRadius::ZERO, hover_fill);
199            }
200        }
201
202        // Render visual guidelines inside the tree cells
203        let is_tree_changed = if cell.col_nr == 0 && self.provider.is_tree() {
204            if let Some(hierarchy) = self.provider.row_hierarchy(self.state, row_idx) {
205                self.state.show_tree_cell(ui, row_idx, hierarchy)
206            } else {
207                false
208            }
209        } else {
210            false
211        };
212
213        if is_tree_changed {
214            ui.ctx().request_repaint();
215        }
216
217        // Set up cell text color matching selection state
218        let text_color = if is_selected {
219            ui.visuals().selection.stroke.color
220        } else {
221            ui.visuals().widgets.inactive.text_color()
222        };
223
224        // Adjust the click-sensing area on Column 0 to protect expand/collapse button hits.
225        // Only the arrow itself (14px) plus its surrounding spacing is carved out; the
226        // indent strip left of it keeps its own interact zone so clicks there still select
227        // the row. Rows without children have a disabled placeholder arrow, so no
228        // carve-out is needed.
229        let mut interact_rect = cell_rect;
230        let mut indent_strip_rect = None;
231        if cell.col_nr == 0
232            && self.provider.is_tree()
233            && let Some(hierarchy) = self.provider.row_hierarchy(self.state, row_idx)
234            && hierarchy.has_children
235        {
236            #[allow(clippy::cast_precision_loss)]
237            let indent_width = hierarchy.indent_level as f32 * 22.0;
238            let strip_end = (interact_rect.min.x + indent_width).min(interact_rect.max.x);
239            if strip_end > interact_rect.min.x {
240                indent_strip_rect = Some(egui::Rect::from_min_max(
241                    interact_rect.min,
242                    egui::pos2(strip_end, interact_rect.max.y),
243                ));
244            }
245            // Item spacing (8px) + arrow (14px) + trailing gap (4px)
246            interact_rect.min.x = (strip_end + 26.0).min(interact_rect.max.x);
247        }
248
249        // Set up cell interaction triggers using layout coordinates to prevent transition collisions
250        let mut handle_row_click = |response: &egui::Response| {
251            if response.clicked() {
252                *self.item_clicked = Some(row_idx);
253                self.state
254                    .handle_row_selection(ui.input(|i| i.modifiers), row_idx);
255            }
256            if response.secondary_clicked() {
257                *self.secondary_clicked = Some(row_idx);
258            }
259        };
260
261        let response = ui.interact(
262            interact_rect,
263            ui.id().with((cell.row_nr, cell.col_nr)),
264            Sense::click(),
265        );
266        handle_row_click(&response);
267
268        if let Some(strip_rect) = indent_strip_rect {
269            let strip_response = ui.interact(
270                strip_rect,
271                ui.id().with((cell.row_nr, cell.col_nr, "indent_strip")),
272                Sense::click(),
273            );
274            handle_row_click(&strip_response);
275        }
276
277        // Construct the zero-allocation borrowed row representation
278        let borrowed_row = BorrowedRow {
279            provider: self.provider,
280            row_index: row_idx,
281        };
282
283        let mut rendered = false;
284
285        // Custom padding configuration to pull Column 0 snug to the expand arrow ONLY when rendering a tree
286        let mut padding = self.cell_padding;
287        if cell.col_nr == 0 && self.provider.is_tree() {
288            padding.left = 0;
289            padding.right = 4;
290        }
291
292        // Wrap everything inside an inner-margin Frame to provide clean cell margins
293        egui::Frame::NONE.inner_margin(padding).show(ui, |ui| {
294            ui.push_id((cell.row_nr, cell.col_nr), |ui| {
295                let custom_renderer = self.custom_cell_ui.as_mut();
296
297                if let Some(renderer) = custom_renderer
298                    && renderer(ui, cell, &borrowed_row as &dyn Row, text_color).is_some()
299                {
300                    rendered = true;
301                }
302
303                if !rendered && let Some((val, _)) = borrowed_row.cell(cell.col_nr) {
304                    ui.horizontal(|ui| {
305                        ui.add(
306                            egui::Label::new(egui::RichText::new(val.as_ref()).color(text_color))
307                                .selectable(false)
308                                .wrap_mode(if ui.is_sizing_pass() {
309                                    ui.wrap_mode()
310                                } else {
311                                    egui::TextWrapMode::Truncate
312                                }),
313                        );
314                    });
315                }
316            });
317        });
318    }
319}