Skip to main content

egui_data_table/draw/
mod.rs

1use std::mem::{replace, take};
2
3use egui::{
4    Align, Color32, Event, Label, Layout, PointerButton, PopupAnchor, Rect, Response, RichText,
5    Sense, Stroke, StrokeKind, Tooltip, Vec2b,
6};
7use egui_extras::Column;
8use tap::prelude::{Pipe, Tap};
9
10use crate::{
11    DataTable, UiAction,
12    viewer::{EmptyRowCreateContext, RowViewer},
13};
14
15use self::state::*;
16
17use egui::scroll_area::ScrollBarVisibility;
18use format as f;
19use std::sync::Arc;
20
21mod body;
22pub(crate) mod state;
23mod tsv;
24
25/* -------------------------------------------- Style ------------------------------------------- */
26
27/// Style configuration for the table.
28// TODO: Implement more style configurations.
29#[derive(Default, Debug, Clone, Copy)]
30#[non_exhaustive]
31pub struct Style {
32    /// Background color override for selection. Default uses `visuals.selection.bg_fill`.
33    pub bg_selected_cell: Option<egui::Color32>,
34
35    /// Background color override for selected cell. Default uses `visuals.selection.bg_fill`.
36    pub bg_selected_highlight_cell: Option<egui::Color32>,
37
38    /// Foreground color override for selected cell. Default uses `visuals.strong_text_colors`.
39    pub fg_selected_highlight_cell: Option<egui::Color32>,
40
41    /// Foreground color for cells that are going to be selected when mouse is dropped.
42    pub fg_drag_selection: Option<egui::Color32>,
43
44    /* ·························································································· */
45    /// Maximum number of undo history. This is applied when actual action is performed.
46    ///
47    /// Setting value '0' results in kinda appropriate default value.
48    pub max_undo_history: usize,
49
50    /// If specify this as [`None`], the heterogeneous row height will be used.
51    pub table_row_height: Option<f32>,
52
53    /// When enabled, single click on a cell will start editing mode. Default is `false` where
54    /// double action(click 1: select, click 2: edit) is required.
55    pub single_click_edit_mode: bool,
56
57    /// How to align cell contents. Default is left-aligned.
58    pub cell_align: egui::Align,
59
60    /// Color to use for the stroke above/below focused row.
61    /// If `None`, defaults to a darkened `warn_fg_color`.
62    pub focused_row_stroke: Option<egui::Color32>,
63
64    /// See [`ScrollArea::auto_shrink`] for details.
65    pub auto_shrink: Vec2b,
66
67    /// See ['ScrollArea::ScrollBarVisibility`] for details.
68    pub scroll_bar_visibility: ScrollBarVisibility,
69}
70
71/* ------------------------------------------ Rendering ----------------------------------------- */
72
73pub struct Renderer<'a, R, V: RowViewer<R>> {
74    table: &'a mut DataTable<R>,
75    viewer: &'a mut V,
76    state: Option<Box<UiState<R>>>,
77    style: Style,
78    translator: Arc<dyn Translator>,
79}
80
81impl<R, V: RowViewer<R>> egui::Widget for Renderer<'_, R, V> {
82    fn ui(self, ui: &mut egui::Ui) -> Response {
83        self.show(ui)
84    }
85}
86
87impl<'a, R, V: RowViewer<R>> Renderer<'a, R, V> {
88    pub fn new(table: &'a mut DataTable<R>, viewer: &'a mut V) -> Self {
89        if table.rows.is_empty() && viewer.allow_row_insertions() {
90            table.push(viewer.new_empty_row_for(EmptyRowCreateContext::InsertNewLine));
91        }
92
93        Self {
94            state: Some(table.ui.take().unwrap_or_default().tap_mut(|state| {
95                state.validate_identity(viewer);
96            })),
97            table,
98            viewer,
99            style: Default::default(),
100            translator: Arc::new(EnglishTranslator::default()),
101        }
102    }
103
104    pub fn with_style(mut self, style: Style) -> Self {
105        self.style = style;
106        self
107    }
108
109    pub fn with_style_modify(mut self, f: impl FnOnce(&mut Style)) -> Self {
110        f(&mut self.style);
111        self
112    }
113
114    pub fn with_table_row_height(mut self, height: f32) -> Self {
115        self.style.table_row_height = Some(height);
116        self
117    }
118
119    pub fn with_max_undo_history(mut self, max_undo_history: usize) -> Self {
120        self.style.max_undo_history = max_undo_history;
121        self
122    }
123
124    /// Sets a custom translator for the instance.
125    /// # Example
126    ///
127    /// ```
128    /// // Define a simple translator
129    /// struct EsEsTranslator;
130    /// impl Translator for EsEsTranslator {
131    ///     fn translate(&self, key: &str) -> String {
132    ///         match key {
133    ///             "hello" => "Hola".to_string(),
134    ///             "world" => "Mundo".to_string(),
135    ///             _ => key.to_string(),
136    ///         }
137    ///     }
138    /// }
139    ///
140    /// let renderer = Renderer::new(&mut table, &mut viewer)
141    ///     .with_translator(Arc::new(EsEsTranslator));
142    /// ```
143    #[cfg(not(doctest))]
144    pub fn with_translator(mut self, translator: Arc<dyn Translator>) -> Self {
145        self.translator = translator;
146        self
147    }
148
149    pub fn show(self, ui: &mut egui::Ui) -> Response {
150        egui::ScrollArea::horizontal()
151            .show(ui, |ui| self.impl_show(ui))
152            .inner
153    }
154
155    fn impl_show(mut self, ui: &mut egui::Ui) -> Response {
156        let ctx = &ui.ctx().clone();
157        let ui_id = ui.id();
158        let style = ui.style().clone();
159        let painter = ui.painter().clone();
160        let visual = &style.visuals;
161        let viewer = &mut *self.viewer;
162        let s = self.state.as_mut().unwrap();
163        let mut resp_total = None::<Response>;
164        let mut resp_ret = None::<Response>;
165        let mut commands = Vec::<Command<R>>::new();
166        let ui_layer_id = ui.layer_id();
167
168        // NOTE: unlike RED and YELLOW which can be acquirable through 'error_bg_color' and
169        // 'warn_bg_color', there's no 'green' color which can be acquired from inherent theme.
170        // Following logic simply gets 'green' color from current background's brightness.
171        let green = if visual.window_fill.g() > 128 {
172            Color32::DARK_GREEN
173        } else {
174            Color32::GREEN
175        };
176
177        let mut builder = egui_extras::TableBuilder::new(ui).column(Column::auto());
178
179        let iter_vis_cols_with_flag = s
180            .vis_cols()
181            .iter()
182            .enumerate()
183            .map(|(index, column)| (column, index + 1 == s.vis_cols().len()));
184
185        for (column, flag) in iter_vis_cols_with_flag {
186            builder = builder.column(viewer.column_render_config(column.0, flag));
187        }
188
189        if replace(&mut s.cci_want_move_scroll, false) {
190            let interact_row = s.interactive_cell().0;
191            builder = builder.scroll_to_row(interact_row.0, None);
192        }
193
194        builder
195            .columns(Column::auto(), s.num_columns() - s.vis_cols().len())
196            .drag_to_scroll(false) // Drag is used for selection;
197            .striped(true)
198            .cell_layout(egui::Layout::default().with_cross_align(self.style.cell_align))
199            .max_scroll_height(f32::MAX)
200            .auto_shrink(self.style.auto_shrink)
201            .scroll_bar_visibility(self.style.scroll_bar_visibility)
202            .sense(Sense::click_and_drag().tap_mut(|s| s.set(Sense::FOCUSABLE, true)))
203            .header(20., |mut h| {
204                h.col(|_ui| {
205                    // TODO: Add `Configure Sorting` button
206                });
207
208                let has_any_hidden_col = s.vis_cols().len() != s.num_columns();
209
210                for (vis_col, &col) in s.vis_cols().iter().enumerate() {
211                    let vis_col = VisColumnPos(vis_col);
212                    let mut painter = None;
213                    let (col_rect, resp) = h.col(|ui| {
214                        egui::Sides::new().show(
215                            ui,
216                            |ui| {
217                                ui.add(Label::new(viewer.column_name(col.0)).selectable(false));
218                            },
219                            |ui| {
220                                if let Some(pos) = s.sort().iter().position(|(c, ..)| c == &col) {
221                                    let is_asc = s.sort()[pos].1.0 as usize;
222
223                                    ui.colored_label(
224                                        [green, Color32::RED][is_asc],
225                                        RichText::new(
226                                            format!("{}{}", ["↘", "↗"][is_asc], pos + 1,),
227                                        )
228                                        .monospace(),
229                                    );
230                                } else {
231                                    // calculate the maximum width for the sort indicator
232                                    let max_sort_indicator_width =
233                                        (s.num_columns() + 1).to_string().len() + 1;
234                                    // when the sort indicator is present, create a label the same size as the sort indicator
235                                    // so that the columns don't resize when sorted.
236                                    ui.add(
237                                        Label::new(
238                                            RichText::new(" ".repeat(max_sort_indicator_width))
239                                                .monospace(),
240                                        )
241                                        .selectable(false),
242                                    );
243                                }
244                            },
245                        );
246
247                        painter = Some(ui.painter().clone());
248                    });
249
250                    // Set drag payload for column reordering.
251                    resp.dnd_set_drag_payload(vis_col);
252
253                    if resp.dragged() {
254                        Tooltip::always_open(
255                            ctx.clone(),
256                            ui_layer_id,
257                            "_EGUI_DATATABLE__COLUMN_MOVE__".into(),
258                            PopupAnchor::Pointer,
259                        )
260                        .gap(12.0)
261                        .show(|ui| {
262                            let colum_name = viewer.column_name(col.0);
263                            ui.label(colum_name);
264                        });
265                    }
266
267                    if resp.hovered() && viewer.is_sortable_column(col.0) {
268                        if let Some(p) = &painter {
269                            p.rect_filled(
270                                col_rect,
271                                egui::CornerRadius::ZERO,
272                                visual.selection.bg_fill.gamma_multiply(0.2),
273                            );
274                        }
275                    }
276
277                    if viewer.is_sortable_column(col.0) && resp.clicked_by(PointerButton::Primary) {
278                        let mut sort = s.sort().to_owned();
279                        match sort.iter_mut().find(|(c, ..)| c == &col) {
280                            Some((_, asc)) => match asc.0 {
281                                true => asc.0 = false,
282                                false => sort.retain(|(c, ..)| c != &col),
283                            },
284                            None => {
285                                sort.push((col, IsAscending(true)));
286                            }
287                        }
288
289                        commands.push(Command::SetColumnSort(sort));
290                    }
291
292                    if resp.dnd_hover_payload::<VisColumnPos>().is_some() {
293                        if let Some(p) = &painter {
294                            p.rect_filled(
295                                col_rect,
296                                egui::CornerRadius::ZERO,
297                                visual.selection.bg_fill.gamma_multiply(0.5),
298                            );
299                        }
300                    }
301
302                    if let Some(payload) = resp.dnd_release_payload::<VisColumnPos>() {
303                        commands.push(Command::CcReorderColumn {
304                            from: *payload,
305                            to: vis_col
306                                .0
307                                .pipe(|v| v + (payload.0 < v) as usize)
308                                .pipe(VisColumnPos),
309                        })
310                    }
311
312                    resp.context_menu(|ui| {
313                        if ui
314                            .button(self.translator.translate("context-menu-hide"))
315                            .clicked()
316                        {
317                            commands.push(Command::CcHideColumn(col));
318                        }
319
320                        if !s.sort().is_empty()
321                            && ui
322                                .button(self.translator.translate("context-menu-clear-sort"))
323                                .clicked()
324                        {
325                            commands.push(Command::SetColumnSort(Vec::new()));
326                        }
327
328                        if has_any_hidden_col {
329                            ui.separator();
330                            ui.label(self.translator.translate("context-menu-hidden"));
331
332                            for col in (0..s.num_columns()).map(ColumnIdx) {
333                                if !s.vis_cols().contains(&col)
334                                    && ui.button(viewer.column_name(col.0)).clicked()
335                                {
336                                    commands.push(Command::CcShowColumn {
337                                        what: col,
338                                        at: vis_col,
339                                    });
340                                }
341                            }
342                        }
343                    });
344                }
345
346                // Account for header response to calculate total response.
347                resp_total = Some(h.response());
348            })
349            .tap_mut(|table| {
350                table.ui_mut().separator();
351            })
352            .body(|body: egui_extras::TableBody<'_>| {
353                resp_ret = Some(
354                    self.impl_show_body(body, painter, commands, ctx, &style, ui_id, resp_total),
355                );
356            });
357
358        resp_ret.unwrap_or_else(|| ui.label("??"))
359    }
360}
361
362impl<R, V: RowViewer<R>> Drop for Renderer<'_, R, V> {
363    fn drop(&mut self) {
364        self.table.ui = self.state.take();
365    }
366}
367
368/* ------------------------------------------- Translations ------------------------------------- */
369
370pub trait Translator {
371    /// Translates a given key into its corresponding string representation.
372    ///
373    /// If the translation key is unknown, return the key as a [`String`]
374    fn translate(&self, key: &str) -> String;
375}
376
377#[derive(Default)]
378pub struct EnglishTranslator {}
379
380impl Translator for EnglishTranslator {
381    fn translate(&self, key: &str) -> String {
382        match key {
383            // cell context menu
384            "context-menu-selection-copy" => "Selection: Copy",
385            "context-menu-selection-cut" => "Selection: Cut",
386            "context-menu-selection-clear" => "Selection: Clear",
387            "context-menu-selection-fill" => "Selection: Fill",
388            "context-menu-clipboard-paste" => "Clipboard: Paste",
389            "context-menu-clipboard-insert" => "Clipboard: Insert",
390            "context-menu-row-duplicate" => "Row: Duplicate",
391            "context-menu-row-delete" => "Row: Delete",
392            "context-menu-undo" => "Undo",
393            "context-menu-redo" => "Redo",
394
395            // column header context menu
396            "context-menu-hide" => "Hide",
397            "context-menu-hidden" => "Hidden",
398            "context-menu-clear-sort" => "Clear sort",
399            _ => key,
400        }
401        .to_string()
402    }
403}