egui_data_table/
draw.rs

1use std::mem::{replace, take};
2
3use egui::{Align, Color32, CornerRadius, Event, Label, Layout, PointerButton, PopupAnchor, Rect, Response, RichText, Sense, Stroke, StrokeKind, Tooltip, Vec2b};
4use egui_extras::Column;
5use tap::prelude::{Pipe, Tap};
6
7use crate::{
8    viewer::{EmptyRowCreateContext, RowViewer},
9    DataTable, UiAction,
10};
11
12use self::state::*;
13
14use format as f;
15use std::sync::Arc;
16use egui::scroll_area::ScrollBarVisibility;
17
18pub(crate) mod state;
19mod tsv;
20
21/* -------------------------------------------- Style ------------------------------------------- */
22
23/// Style configuration for the table.
24// TODO: Implement more style configurations.
25#[derive(Default, Debug, Clone, Copy)]
26#[non_exhaustive]
27pub struct Style {
28    /// Background color override for selection. Default uses `visuals.selection.bg_fill`.
29    pub bg_selected_cell: Option<egui::Color32>,
30
31    /// Background color override for selected cell. Default uses `visuals.selection.bg_fill`.
32    pub bg_selected_highlight_cell: Option<egui::Color32>,
33
34    /// Foreground color override for selected cell. Default uses `visuals.strong_text_colors`.
35    pub fg_selected_highlight_cell: Option<egui::Color32>,
36
37    /// Foreground color for cells that are going to be selected when mouse is dropped.
38    pub fg_drag_selection: Option<egui::Color32>,
39
40    /* ·························································································· */
41    /// Maximum number of undo history. This is applied when actual action is performed.
42    ///
43    /// Setting value '0' results in kinda appropriate default value.
44    pub max_undo_history: usize,
45
46    /// If specify this as [`None`], the heterogeneous row height will be used.
47    pub table_row_height: Option<f32>,
48
49    /// When enabled, single click on a cell will start editing mode. Default is `false` where
50    /// double action(click 1: select, click 2: edit) is required.
51    pub single_click_edit_mode: bool,
52
53    /// How to align cell contents. Default is left-aligned.
54    pub cell_align: egui::Align,
55
56    /// Color to use for the stroke above/below focused row.
57    /// If `None`, defaults to a darkened `warn_fg_color`.
58    pub focused_row_stroke: Option<egui::Color32>,
59
60    /// See [`ScrollArea::auto_shrink`] for details.
61    pub auto_shrink: Vec2b,
62
63    /// See ['ScrollArea::ScrollBarVisibility`] for details.
64    pub scroll_bar_visibility: ScrollBarVisibility,
65}
66
67/* ------------------------------------------ Rendering ----------------------------------------- */
68
69pub struct Renderer<'a, R, V: RowViewer<R>> {
70    table: &'a mut DataTable<R>,
71    viewer: &'a mut V,
72    state: Option<Box<UiState<R>>>,
73    style: Style,
74    translator: Arc<dyn Translator>
75}
76
77impl<R, V: RowViewer<R>> egui::Widget for Renderer<'_, R, V> {
78    fn ui(self, ui: &mut egui::Ui) -> Response {
79        self.show(ui)
80    }
81}
82
83impl<'a, R, V: RowViewer<R>> Renderer<'a, R, V> {
84    pub fn new(table: &'a mut DataTable<R>, viewer: &'a mut V) -> Self {
85        if table.rows.is_empty() && viewer.allow_row_insertions() {
86            table.push(viewer.new_empty_row_for(EmptyRowCreateContext::InsertNewLine));
87        }
88
89        Self {
90            state: Some(table.ui.take().unwrap_or_default().tap_mut(|state| {
91                state.validate_identity(viewer);
92            })),
93            table,
94            viewer,
95            style: Default::default(),
96            translator: Arc::new(EnglishTranslator::default()),
97        }
98    }
99
100    pub fn with_style(mut self, style: Style) -> Self {
101        self.style = style;
102        self
103    }
104
105    pub fn with_style_modify(mut self, f: impl FnOnce(&mut Style)) -> Self {
106        f(&mut self.style);
107        self
108    }
109
110    pub fn with_table_row_height(mut self, height: f32) -> Self {
111        self.style.table_row_height = Some(height);
112        self
113    }
114
115    pub fn with_max_undo_history(mut self, max_undo_history: usize) -> Self {
116        self.style.max_undo_history = max_undo_history;
117        self
118    }
119
120    /// Sets a custom translator for the instance.
121    /// # Example
122    ///
123    /// ```
124    /// // Define a simple translator
125    /// struct EsEsTranslator;
126    /// impl Translator for EsEsTranslator {
127    ///     fn translate(&self, key: &str) -> String {
128    ///         match key {
129    ///             "hello" => "Hola".to_string(),
130    ///             "world" => "Mundo".to_string(),
131    ///             _ => key.to_string(),
132    ///         }
133    ///     }
134    /// }
135    ///
136    /// let renderer = Renderer::new(&mut table, &mut viewer)
137    ///     .with_translator(Arc::new(EsEsTranslator));
138    /// ```
139    #[cfg(not(doctest))]
140    pub fn with_translator(mut self, translator: Arc<dyn Translator>) -> Self {
141        self.translator = translator;
142        self
143    }
144
145    pub fn show(self, ui: &mut egui::Ui) -> Response {
146        egui::ScrollArea::horizontal()
147            .show(ui, |ui| self.impl_show(ui))
148            .inner
149    }
150
151    fn impl_show(mut self, ui: &mut egui::Ui) -> Response {
152        let ctx = &ui.ctx().clone();
153        let ui_id = ui.id();
154        let style = ui.style().clone();
155        let painter = ui.painter().clone();
156        let visual = &style.visuals;
157        let viewer = &mut *self.viewer;
158        let s = self.state.as_mut().unwrap();
159        let mut resp_total = None::<Response>;
160        let mut resp_ret = None::<Response>;
161        let mut commands = Vec::<Command<R>>::new();
162        let ui_layer_id = ui.layer_id();
163
164        // NOTE: unlike RED and YELLOW which can be acquirable through 'error_bg_color' and
165        // 'warn_bg_color', there's no 'green' color which can be acquired from inherent theme.
166        // Following logic simply gets 'green' color from current background's brightness.
167        let green = if visual.window_fill.g() > 128 {
168            Color32::DARK_GREEN
169        } else {
170            Color32::GREEN
171        };
172
173        let mut builder = egui_extras::TableBuilder::new(ui).column(Column::auto());
174
175        let iter_vis_cols_with_flag = s
176            .vis_cols()
177            .iter()
178            .enumerate()
179            .map(|(index, column)| (column, index + 1 == s.vis_cols().len()));
180
181        for (column, flag) in iter_vis_cols_with_flag {
182            builder = builder.column(viewer.column_render_config(column.0, flag));
183        }
184
185        if replace(&mut s.cci_want_move_scroll, false) {
186            let interact_row = s.interactive_cell().0;
187            builder = builder.scroll_to_row(interact_row.0, None);
188        }
189
190        builder
191            .columns(Column::auto(), s.num_columns() - s.vis_cols().len())
192            .drag_to_scroll(false) // Drag is used for selection;
193            .striped(true)
194            .cell_layout(egui::Layout::default().with_cross_align(self.style.cell_align))
195            .max_scroll_height(f32::MAX)
196            .auto_shrink(self.style.auto_shrink)
197            .scroll_bar_visibility(self.style.scroll_bar_visibility)
198            .sense(Sense::click_and_drag().tap_mut(|s| s.set(Sense::FOCUSABLE, true)))
199            .header(20., |mut h| {
200                h.col(|_ui| {
201                    // TODO: Add `Configure Sorting` button
202                });
203
204                let has_any_hidden_col = s.vis_cols().len() != s.num_columns();
205
206                for (vis_col, &col) in s.vis_cols().iter().enumerate() {
207                    let vis_col = VisColumnPos(vis_col);
208                    let mut painter = None;
209                    let (col_rect, resp) = h.col(|ui| {
210                        egui::Sides::new().show(ui, |ui| {
211                            ui.add(Label::new(viewer.column_name(col.0))
212                                .selectable(false)
213                            );
214                        }, |ui|{
215                            if let Some(pos) = s.sort().iter().position(|(c, ..)| c == &col) {
216                                let is_asc = s.sort()[pos].1 .0 as usize;
217
218                                ui.colored_label(
219                                    [green, Color32::RED][is_asc],
220                                    RichText::new(format!("{}{}", ["↘", "↗"][is_asc], pos + 1,))
221                                        .monospace(),
222                                );
223                            } else {
224                                // calculate the maximum width for the sort indicator
225                                let max_sort_indicator_width = (s.num_columns() + 1).to_string().len() + 1;
226                                // when the sort indicator is present, create a label the same size as the sort indicator
227                                // so that the columns don't resize when sorted.
228                                ui.add(Label::new(RichText::new(" ".repeat(max_sort_indicator_width)).monospace()).selectable(false));
229                            }
230                        });
231
232                        painter = Some(ui.painter().clone());
233                    });
234
235                    // Set drag payload for column reordering.
236                    resp.dnd_set_drag_payload(vis_col);
237
238                    if resp.dragged() {
239                        Tooltip::always_open(ctx.clone(), ui_layer_id, "_EGUI_DATATABLE__COLUMN_MOVE__".into(), PopupAnchor::Pointer)
240                            .gap(12.0)
241                            .show(|ui|{
242                                let colum_name = viewer.column_name(col.0);
243                                ui.label(colum_name);
244                            });
245                    }
246
247                    if resp.hovered() && viewer.is_sortable_column(col.0) {
248                        if let Some(p) = &painter {
249                            p.rect_filled(
250                                col_rect,
251                                egui::CornerRadius::ZERO,
252                                visual.selection.bg_fill.gamma_multiply(0.2),
253                            );
254                        }
255                    }
256
257                    if viewer.is_sortable_column(col.0) && resp.clicked_by(PointerButton::Primary) {
258                        let mut sort = s.sort().to_owned();
259                        match sort.iter_mut().find(|(c, ..)| c == &col) {
260                            Some((_, asc)) => match asc.0 {
261                                true => asc.0 = false,
262                                false => sort.retain(|(c, ..)| c != &col),
263                            },
264                            None => {
265                                sort.push((col, IsAscending(true)));
266                            }
267                        }
268
269                        commands.push(Command::SetColumnSort(sort));
270                    }
271
272                    if resp.dnd_hover_payload::<VisColumnPos>().is_some() {
273                        if let Some(p) = &painter {
274                            p.rect_filled(
275                                col_rect,
276                                egui::CornerRadius::ZERO,
277                                visual.selection.bg_fill.gamma_multiply(0.5),
278                            );
279                        }
280                    }
281
282                    if let Some(payload) = resp.dnd_release_payload::<VisColumnPos>() {
283                        commands.push(Command::CcReorderColumn {
284                            from: *payload,
285                            to: vis_col
286                                .0
287                                .pipe(|v| v + (payload.0 < v) as usize)
288                                .pipe(VisColumnPos),
289                        })
290                    }
291
292                    resp.context_menu(|ui| {
293                        if ui.button(self.translator.translate("context-menu-hide")).clicked() {
294                            commands.push(Command::CcHideColumn(col));
295                        }
296
297                        if !s.sort().is_empty() && ui.button(self.translator.translate("context-menu-clear-sort")).clicked() {
298                            commands.push(Command::SetColumnSort(Vec::new()));
299                        }
300
301                        if has_any_hidden_col {
302                            ui.separator();
303                            ui.label(self.translator.translate("context-menu-hidden"));
304
305                            for col in (0..s.num_columns()).map(ColumnIdx) {
306                                if !s.vis_cols().contains(&col)
307                                    && ui.button(viewer.column_name(col.0)).clicked()
308                                {
309                                    commands.push(Command::CcShowColumn {
310                                        what: col,
311                                        at: vis_col,
312                                    });
313                                }
314                            }
315                        }
316                    });
317                }
318
319                // Account for header response to calculate total response.
320                resp_total = Some(h.response());
321            })
322            .tap_mut(|table| {
323                table.ui_mut().separator();
324            })
325            .body(|body: egui_extras::TableBody<'_>| {
326                resp_ret = Some(
327                    self.impl_show_body(body, painter, commands, ctx, &style, ui_id, resp_total),
328                );
329            });
330
331        resp_ret.unwrap_or_else(|| ui.label("??"))
332    }
333
334    #[allow(clippy::too_many_arguments)]
335    fn impl_show_body(
336        &mut self,
337        body: egui_extras::TableBody<'_>,
338        mut _painter: egui::Painter,
339        mut commands: Vec<Command<R>>,
340        ctx: &egui::Context,
341        style: &egui::Style,
342        ui_id: egui::Id,
343        mut resp_total: Option<Response>,
344    ) -> Response {
345        let viewer = &mut *self.viewer;
346        let s = self.state.as_mut().unwrap();
347        let table = &mut *self.table;
348        let visual = &style.visuals;
349        let visible_cols = s.vis_cols().clone();
350        let no_rounding = egui::CornerRadius::ZERO;
351
352        let mut actions = Vec::<UiAction>::new();
353        let mut edit_started = false;
354        let hotkeys = viewer.hotkeys(&s.ui_action_context());
355
356        // Preemptively consume all hotkeys.
357        'detect_hotkey: {
358            // Detect hotkey inputs only when the table has focus. While editing, let the
359            // editor consume input.
360            if !s.cci_has_focus {
361                break 'detect_hotkey;
362            }
363
364            if !s.is_editing() {
365                ctx.input_mut(|i| {
366                    i.events.retain(|x| {
367                        match x {
368                            Event::Copy => actions.push(UiAction::CopySelection),
369                            Event::Cut => actions.push(UiAction::CutSelection),
370
371                            // Try to parse clipboard contents and detect if it's compatible
372                            // with cells being pasted.
373                            Event::Paste(clipboard) => {
374                                if !clipboard.is_empty() {
375                                    // If system clipboard is not empty, try to update the internal
376                                    // clipboard with system clipboard content before applying
377                                    // paste operation.
378                                    s.try_update_clipboard_from_string(viewer, clipboard);
379                                }
380
381                                if i.modifiers.shift {
382                                    if viewer.allow_row_insertions() {
383                                        actions.push(UiAction::PasteInsert)
384                                    }
385                                } else {
386                                    actions.push(UiAction::PasteInPlace)
387                                }
388                            }
389
390                            _ => return true,
391                        }
392                        false
393                    })
394                });
395            }
396
397            for (hotkey, action) in &hotkeys {
398                ctx.input_mut(|inp| {
399                    if inp.consume_shortcut(hotkey) {
400                        actions.push(*action);
401                    }
402                })
403            }
404        }
405
406        // Validate persistency state.
407        #[cfg(feature = "persistency")]
408        if viewer.persist_ui_state() {
409            s.validate_persistency(ctx, ui_id, viewer);
410        }
411
412        // Validate ui state. Defer this as late as possible; since it may not be
413        // called if the table area is out of the visible space.
414        s.validate_cc(&mut table.rows, viewer);
415
416        // Checkout `cc_rows` to satisfy borrow checker. We need to access to
417        // state mutably within row rendering; therefore, we can't simply borrow
418        // `cc_rows` during the whole logic!
419        let cc_row_heights = take(&mut s.cc_row_heights);
420
421        let mut row_height_updates = Vec::new();
422        let vis_row_digits = s.cc_rows.len().max(1).ilog10();
423        let row_id_digits = table.len().max(1).ilog10();
424
425        let body_max_rect = body.max_rect();
426        let has_any_sort = !s.sort().is_empty();
427
428        let pointer_interact_pos = ctx.input(|i| i.pointer.latest_pos().unwrap_or_default());
429        let pointer_primary_down = ctx.input(|i| i.pointer.button_down(PointerButton::Primary));
430
431        s.cci_page_row_count = 0;
432
433        /* ----------------------------- Primary Rendering Function ----------------------------- */
434        // - Extracted as a closure to differentiate behavior based on row height
435        //   configuration. (heterogeneous or homogeneous row heights)
436
437        let render_fn = |mut row: egui_extras::TableRow| {
438            s.cci_page_row_count += 1;
439
440            let vis_row = VisRowPos(row.index());
441            let row_id = s.cc_rows[vis_row.0];
442            let prev_row_height = cc_row_heights[vis_row.0];
443
444            let mut row_elem_start = Default::default();
445
446            // Check if current row is edition target
447            let edit_state = s.row_editing_cell(row_id);
448            let mut editing_cell_rect = Rect::NOTHING;
449            let interactive_row = s.is_interactive_row(vis_row);
450
451            let check_mouse_dragging_selection = {
452                let s_cci_has_focus = s.cci_has_focus;
453                let s_cci_has_selection = s.has_cci_selection();
454
455                move |rect: &Rect, resp: &egui::Response| {
456                    let cci_hovered: bool = s_cci_has_focus
457                        && s_cci_has_selection
458                        && rect
459                            .with_max_x(resp.rect.right())
460                            .contains(pointer_interact_pos);
461                    let sel_drag = cci_hovered && pointer_primary_down;
462                    let sel_click = !s_cci_has_selection && resp.hovered() && pointer_primary_down;
463
464                    sel_drag || sel_click
465                }
466            };
467
468            /* -------------------------------- Header Rendering -------------------------------- */
469
470            // Mark row background filled if being edited.
471            row.set_selected(edit_state.is_some());
472
473            // Render row header button
474            let (head_rect, head_resp) = row.col(|ui| {
475                // Calculate the position where values start.
476                row_elem_start = ui.max_rect().right_top();
477
478                ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
479                    ui.separator();
480
481                    if has_any_sort {
482                        ui.monospace(
483                            RichText::from(f!(
484                                "{:·>width$}",
485                                row_id.0,
486                                width = row_id_digits as usize
487                            ))
488                            .strong(),
489                        );
490                    } else {
491                        ui.monospace(
492                            RichText::from(f!("{:>width$}", "", width = row_id_digits as usize))
493                                .strong(),
494                        );
495                    }
496
497                    ui.monospace(
498                        RichText::from(f!(
499                            "{:·>width$}",
500                            vis_row.0 + 1,
501                            width = vis_row_digits as usize
502                        ))
503                        .weak(),
504                    );
505                });
506            });
507
508            if check_mouse_dragging_selection(&head_rect, &head_resp) {
509                s.cci_sel_update_row(vis_row);
510            }
511
512            /* -------------------------------- Columns Rendering ------------------------------- */
513
514            // Overridable maximum height
515            let mut new_maximum_height = 0.;
516
517            // Render cell contents regardless of the edition state.
518            for (vis_col, col) in visible_cols.iter().enumerate() {
519                let vis_col = VisColumnPos(vis_col);
520                let linear_index = vis_row.linear_index(visible_cols.len(), vis_col);
521                let selected = s.is_selected(vis_row, vis_col);
522                let cci_selected = s.is_selected_cci(vis_row, vis_col);
523                let is_editing = edit_state.is_some();
524                let is_interactive_cell = interactive_row.is_some_and(|x| x == vis_col);
525                let mut response_consumed = s.is_editing();
526
527                let (rect, resp) = row.col(|ui| {
528                    let ui_max_rect = ui.max_rect();
529
530                    if cci_selected {
531                        ui.painter().rect_stroke(
532                            ui_max_rect,
533                            no_rounding,
534                            Stroke {
535                                width: 2.,
536                                color: self
537                                    .style
538                                    .fg_drag_selection
539                                    .unwrap_or(visual.selection.bg_fill),
540                            },
541                            StrokeKind::Inside,
542                        );
543                    }
544
545                    if is_interactive_cell {
546                        ui.painter().rect_filled(
547                            ui_max_rect.expand(2.),
548                            no_rounding,
549                            self.style
550                                .bg_selected_highlight_cell
551                                .unwrap_or(visual.selection.bg_fill),
552                        );
553                    } else if selected {
554                        ui.painter().rect_filled(
555                            ui_max_rect.expand(1.),
556                            no_rounding,
557                            self.style
558                                .bg_selected_cell
559                                .unwrap_or(visual.selection.bg_fill.gamma_multiply(0.5)),
560                        );
561                    }
562
563                    // Actual widget rendering happens within this line.
564
565                    // ui.set_enabled(false);
566                    ui.style_mut()
567                        .visuals
568                        .widgets
569                        .noninteractive
570                        .fg_stroke
571                        .color = if is_interactive_cell {
572                        self.style
573                            .fg_selected_highlight_cell
574                            .unwrap_or(visual.strong_text_color())
575                    } else {
576                        visual.strong_text_color()
577                    };
578
579                    // FIXME: After egui 0.27, now the widgets spawned inside this closure
580                    // intercepts interactions, which is basically natural behavior(Upper layer
581                    // widgets). However, this change breaks current implementation which relies on
582                    // the previous table behavior.
583                    ui.add_enabled_ui(false, |ui| {
584                        if !(is_editing && is_interactive_cell) {
585                            viewer.show_cell_view(ui, &table.rows[row_id.0], col.0);
586                        }
587                    });
588
589                    #[cfg(any())]
590                    if selected {
591                        ui.painter().rect_stroke(
592                            ui_max_rect,
593                            no_rounding,
594                            Stroke {
595                                width: 1.,
596                                color: visual.weak_text_color(),
597                            },
598                        );
599                    }
600
601                    if interactive_row.is_some() && !is_editing {
602                        let st = Stroke {
603                            width: 1.,
604                            color: self
605                                .style
606                                .focused_row_stroke
607                                .unwrap_or(visual.warn_fg_color.gamma_multiply(0.5)),
608                        };
609
610                        let xr = ui_max_rect.x_range();
611                        let yr = ui_max_rect.y_range();
612                        ui.painter().hline(xr, yr.min, st);
613                        ui.painter().hline(xr, yr.max, st);
614                    }
615
616                    if edit_state.is_some_and(|(_, vis)| vis == vis_col) {
617                        editing_cell_rect = ui_max_rect;
618                    }
619                });
620
621                new_maximum_height = rect.height().max(new_maximum_height);
622
623                // -- Mouse Actions --
624                if check_mouse_dragging_selection(&rect, &resp) {
625                    // Expand cci selection
626                    response_consumed = true;
627                    s.cci_sel_update(linear_index);
628                }
629
630                let editable = viewer.is_editable_cell(vis_col.0, vis_row.0, &table.rows[row_id.0]);
631
632                if editable
633                    && (resp.clicked_by(PointerButton::Primary)
634                        && (self.style.single_click_edit_mode || is_interactive_cell))
635                {
636                    response_consumed = true;
637                    commands.push(Command::CcEditStart(
638                        row_id,
639                        vis_col,
640                        viewer.clone_row(&table.rows[row_id.0]).into(),
641                    ));
642                    edit_started = true;
643                }
644
645                /* --------------------------- Context Menu Rendering --------------------------- */
646
647                (resp.clone() | head_resp.clone()).context_menu(|ui| {
648                    response_consumed = true;
649                    ui.set_min_size(egui::vec2(250., 10.));
650
651                    if !selected {
652                        commands.push(Command::CcSetSelection(vec![VisSelection(
653                            linear_index,
654                            linear_index,
655                        )]));
656                    } else if !is_interactive_cell {
657                        s.set_interactive_cell(vis_row, vis_col);
658                    }
659
660                    let sel_multi_row = s.cursor_as_selection().is_some_and(|sel| {
661                        let mut min = usize::MAX;
662                        let mut max = usize::MIN;
663
664                        for sel in sel {
665                            min = min.min(sel.0 .0);
666                            max = max.max(sel.1 .0);
667                        }
668
669                        let (r_min, _) = VisLinearIdx(min).row_col(s.vis_cols().len());
670                        let (r_max, _) = VisLinearIdx(max).row_col(s.vis_cols().len());
671
672                        r_min != r_max
673                    });
674
675                    let cursor_x = ui.cursor().min.x;
676                    let clip = s.has_clipboard_contents();
677                    let b_undo = s.has_undo();
678                    let b_redo = s.has_redo();
679                    let mut n_sep_menu = 0;
680                    let mut draw_sep = false;
681
682                    let context_menu_items = [
683                        Some((selected, "🖻", "context-menu-selection-copy", UiAction::CopySelection)),
684                        Some((selected, "🖻", "context-menu-selection-cut", UiAction::CutSelection)),
685                        Some((selected, "🗙", "context-menu-selection-clear", UiAction::DeleteSelection)),
686                        Some((
687                            sel_multi_row,
688                            "🗐",
689                            "context-menu-selection-fill",
690                            UiAction::SelectionDuplicateValues,
691                        )),
692                        None,
693                        Some((clip, "➿", "context-menu-clipboard-paste", UiAction::PasteInPlace)),
694                        Some((
695                            clip && viewer.allow_row_insertions(),
696                            "🛠",
697                            "context-menu-clipboard-insert",
698                            UiAction::PasteInsert,
699                        )),
700                        None,
701                        Some((
702                            viewer.allow_row_insertions(),
703                            "🗐",
704                            "context-menu-row-duplicate",
705                            UiAction::DuplicateRow,
706                        )),
707                        Some((
708                            viewer.allow_row_deletions(),
709                            "🗙",
710                            "context-menu-row-delete",
711                            UiAction::DeleteRow,
712                        )),
713                        None,
714                        Some((b_undo, "⎗", "context-menu-undo", UiAction::Undo)),
715                        Some((b_redo, "⎘", "context-menu-redo", UiAction::Redo)),
716                    ];
717
718                    context_menu_items.map(|opt| {
719                        if let Some((icon, key, action)) =
720                            opt.filter(|x| x.0).map(|x| (x.1, x.2, x.3))
721                        {
722                            if draw_sep {
723                                draw_sep = false;
724                                ui.separator();
725                            }
726
727                            let hotkey = hotkeys
728                                .iter()
729                                .find_map(|(k, a)| (a == &action).then(|| ctx.format_shortcut(k)));
730
731                            ui.horizontal(|ui| {
732                                ui.monospace(icon);
733                                ui.add_space(cursor_x + 20. - ui.cursor().min.x);
734
735                                let label = self.translator.translate(key);
736                                let btn = egui::Button::new(label)
737                                    .shortcut_text(hotkey.unwrap_or_else(|| "🗙".into()));
738                                let r = ui.centered_and_justified(|ui| ui.add(btn)).inner;
739
740                                if r.clicked() {
741                                    actions.push(action);
742                                }
743                            });
744
745                            n_sep_menu += 1;
746                        } else if n_sep_menu > 0 {
747                            n_sep_menu = 0;
748                            draw_sep = true;
749                        }
750                    });
751                });
752
753                // Forward DnD event if not any event was consumed by the response.
754
755                // FIXME: Upgrading egui 0.29 make interaction rectangle of response object
756                // larger(in y axis) than actually visible column cell size. To deal with this,
757                // I've used returned content area rectangle instead, expanding its width to
758                // response size.
759
760                let drop_area_rect = rect.with_max_x(resp.rect.max.x);
761                let contains_pointer = ctx
762                    .pointer_hover_pos()
763                    .is_some_and(|pos| drop_area_rect.contains(pos));
764
765                if !response_consumed && contains_pointer {
766                    if let Some(new_value) =
767                        viewer.on_cell_view_response(&table.rows[row_id.0], col.0, &resp)
768                    {
769                        let mut values = vec![(row_id, *col, RowSlabIndex(0))];
770
771                        values.retain(|(row, col, _slab_id)| {
772                            viewer.is_editable_cell(col.0, row.0, &table.rows[row.0])
773                        });
774
775                        commands.push(Command::SetCells {
776                            slab: vec![*new_value].into_boxed_slice(),
777                            values: values.into_boxed_slice(),
778                        });
779                    }
780                }
781            }
782
783            /* -------------------------------- Editor Rendering -------------------------------- */
784            if let Some((should_focus, vis_column)) = edit_state {
785                let column = s.vis_cols()[vis_column.0];
786
787                egui::Window::new("")
788                    .id(ui_id.with(row_id).with(column))
789                    .constrain_to(body_max_rect)
790                    .fixed_pos(editing_cell_rect.min)
791                    .auto_sized()
792                    .min_size(editing_cell_rect.size())
793                    .max_width(editing_cell_rect.width())
794                    .title_bar(false)
795                    .frame(egui::Frame::NONE.corner_radius(egui::CornerRadius::same(3)))
796                    .show(ctx, |ui| {
797                        ui.with_layout(Layout::top_down_justified(Align::LEFT), |ui| {
798                            if let Some(resp) =
799                                viewer.show_cell_editor(ui, s.unwrap_editing_row_data(), column.0)
800                            {
801                                if should_focus {
802                                    resp.request_focus()
803                                }
804
805                                new_maximum_height = resp.rect.height().max(new_maximum_height);
806                            } else {
807                                commands.push(Command::CcCommitEdit);
808                            }
809                        });
810                    });
811            }
812
813            // Accumulate response
814            if let Some(resp) = &mut resp_total {
815                *resp = resp.union(row.response());
816            } else {
817                resp_total = Some(row.response());
818            }
819
820            // Update row height cache if necessary.
821            if self.style.table_row_height.is_none() && prev_row_height != new_maximum_height {
822                row_height_updates.push((vis_row, new_maximum_height));
823            }
824        }; // ~ render_fn
825
826        // Actual rendering
827        if let Some(height) = self.style.table_row_height {
828            body.rows(height, cc_row_heights.len(), render_fn);
829        } else {
830            body.heterogeneous_rows(cc_row_heights.iter().cloned(), render_fn);
831        }
832
833        /* ----------------------------------- Event Handling ----------------------------------- */
834
835        if ctx.input(|i| i.pointer.button_released(PointerButton::Primary)) {
836            let mods = ctx.input(|i| i.modifiers);
837            if let Some(sel) = s.cci_take_selection(mods).filter(|_| !edit_started) {
838                commands.push(Command::CcSetSelection(sel));
839            }
840        }
841
842        // Control overall focus status.
843        if let Some(resp) = resp_total.clone() {
844
845            let clicked_elsewhere = resp.clicked_elsewhere();
846            // IMPORTANT: cannot use `resp.contains_pointer()` here
847            let response_rect_contains_pointer = resp.rect.contains(pointer_interact_pos);
848
849            if resp.clicked() | resp.dragged() {
850                s.cci_has_focus = true;
851            } else if clicked_elsewhere && !response_rect_contains_pointer {
852                s.cci_has_focus = false;
853                if s.is_editing() {
854                    commands.push(Command::CcCommitEdit)
855                }
856            }
857        }
858
859        // Check in borrowed `cc_rows` back to state.
860        s.cc_row_heights = cc_row_heights.tap_mut(|values| {
861            if !row_height_updates.is_empty() {
862                ctx.request_repaint();
863            }
864
865            for (row_index, row_height) in row_height_updates {
866                values[row_index.0] = row_height;
867            }
868        });
869
870        // Handle queued actions
871        commands.extend(
872            actions
873                .into_iter()
874                .flat_map(|action| s.try_apply_ui_action(table, viewer, action)),
875        );
876
877        // Handle queued commands
878        for cmd in commands {
879            match cmd {
880                Command::CcUpdateSystemClipboard(new_content) => {
881                    ctx.copy_text(new_content);
882                }
883                cmd => {
884                    if matches!(cmd, Command::CcCommitEdit) {
885                        // If any commit action is detected, release any remaining focus.
886                        ctx.memory_mut(|x| {
887                            if let Some(fc) = x.focused() {
888                                x.surrender_focus(fc)
889                            }
890                        });
891                    }
892
893                    s.push_new_command(
894                        table,
895                        viewer,
896                        cmd,
897                        if self.style.max_undo_history == 0 {
898                            100
899                        } else {
900                            self.style.max_undo_history
901                        },
902                    );
903                }
904            }
905        }
906
907        // Total response
908        resp_total.unwrap()
909    }
910}
911
912impl<R, V: RowViewer<R>> Drop for Renderer<'_, R, V> {
913    fn drop(&mut self) {
914        self.table.ui = self.state.take();
915    }
916}
917
918/* ------------------------------------------- Translations ------------------------------------- */
919
920pub trait Translator {
921
922    /// Translates a given key into its corresponding string representation.
923    ///
924    /// If the translation key is unknown, return the key as a [`String`]
925    fn translate(&self, key: &str) -> String;
926}
927
928#[derive(Default)]
929pub struct EnglishTranslator {}
930
931impl Translator for EnglishTranslator {
932    fn translate(&self, key: &str) -> String {
933        match key {
934            // cell context menu
935            "context-menu-selection-copy" => "Selection: Copy",
936            "context-menu-selection-cut" => "Selection: Cut",
937            "context-menu-selection-clear" => "Selection: Clear",
938            "context-menu-selection-fill" => "Selection: Fill",
939            "context-menu-clipboard-paste" => "Clipboard: Paste",
940            "context-menu-clipboard-insert" => "Clipboard: Insert",
941            "context-menu-row-duplicate" => "Row: Duplicate",
942            "context-menu-row-delete" => "Row: Delete",
943            "context-menu-undo" => "Undo",
944            "context-menu-redo" => "Redo",
945
946            // column header context menu
947            "context-menu-hide" => "Hide",
948            "context-menu-hidden" => "Hidden",
949            "context-menu-clear-sort" => "Clear sort",
950            _ => key,
951        }.to_string()
952    }
953}