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        let mut interact_rect = cell_rect;
226        if cell.col_nr == 0
227            && self.provider.is_tree()
228            && let Some(hierarchy) = self.provider.row_hierarchy(self.state, row_idx)
229        {
230            #[allow(clippy::cast_precision_loss)]
231            let indent_width = (hierarchy.indent_level as f32).mul_add(22.0, 22.0);
232            interact_rect.min.x = (interact_rect.min.x + indent_width).min(interact_rect.max.x);
233        }
234
235        // Set up cell interaction triggers using layout coordinates to prevent transition collisions
236        let response = ui.interact(
237            interact_rect,
238            ui.id().with((cell.row_nr, cell.col_nr)),
239            Sense::click(),
240        );
241        if response.clicked() {
242            *self.item_clicked = Some(row_idx);
243            self.state
244                .handle_row_selection(ui.input(|i| i.modifiers), row_idx);
245        }
246        if response.secondary_clicked() {
247            *self.secondary_clicked = Some(row_idx);
248        }
249
250        // Construct the zero-allocation borrowed row representation
251        let borrowed_row = BorrowedRow {
252            provider: self.provider,
253            row_index: row_idx,
254        };
255
256        let mut rendered = false;
257
258        // Custom padding configuration to pull Column 0 snug to the expand arrow ONLY when rendering a tree
259        let mut padding = self.cell_padding;
260        if cell.col_nr == 0 && self.provider.is_tree() {
261            padding.left = 0;
262            padding.right = 4;
263        }
264
265        // Wrap everything inside an inner-margin Frame to provide clean cell margins
266        egui::Frame::NONE.inner_margin(padding).show(ui, |ui| {
267            ui.push_id((cell.row_nr, cell.col_nr), |ui| {
268                let custom_renderer = self.custom_cell_ui.as_mut();
269
270                if let Some(renderer) = custom_renderer
271                    && renderer(ui, cell, &borrowed_row as &dyn Row, text_color).is_some()
272                {
273                    rendered = true;
274                }
275
276                if !rendered && let Some((val, _)) = borrowed_row.cell(cell.col_nr) {
277                    ui.horizontal(|ui| {
278                        ui.add(
279                            egui::Label::new(egui::RichText::new(val.as_ref()).color(text_color))
280                                .selectable(false)
281                                .wrap_mode(if ui.is_sizing_pass() {
282                                    ui.wrap_mode()
283                                } else {
284                                    egui::TextWrapMode::Truncate
285                                }),
286                        );
287                    });
288                }
289            });
290        });
291    }
292}