Skip to main content

egui_table_kit/
operations.rs

1use std::borrow::Cow;
2
3use compact_str::ToCompactString as _;
4use fluent_zero::t;
5
6use super::{error::TableError, filter::Filter, state::TableState};
7
8/// A single cell representation containing a primary value and an optional hover/display override.
9pub type TableCell<'a> = (Cow<'a, str>, Option<Cow<'a, str>>);
10
11/// A row slice reference passed sequentially to callbacks during iteration.
12pub type RowSlice<'a, 'b> = &'b [TableCell<'a>];
13
14/// The callback signature used to process streamed row data.
15/// - `'b` represents the lifetime of any local variables captured by the closure.
16/// - `for<'a, 'c>` unifies the string content lifetime `'a` and reference lifetime `'c`,
17///   allowing the callback to process rows with short-lived, local lifetimes.
18pub type RowCallback<'b> = dyn for<'a, 'c> FnMut(RowSlice<'a, 'c>) -> Result<(), TableError> + 'b;
19
20#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize)]
21pub struct RowHierarchy {
22    pub indent_level: usize,
23    pub has_children: bool,
24    pub is_expanded: bool,
25}
26
27pub trait TableProvider {
28    fn headers(&self) -> &[&str];
29    fn row_count(&self) -> usize;
30
31    fn for_selected_rows(
32        &self,
33        state: &TableState,
34        f: &mut RowCallback<'_>,
35    ) -> Result<(), TableError>;
36
37    fn for_all_rows(&self, f: &mut RowCallback<'_>) -> Result<(), TableError>;
38
39    /// Sorts the active row indices by the specified column.
40    /// Uses a generic string-based fallback sorting implementation, but can be overridden.
41    fn sort_active_rows(
42        &self,
43        active_rows: &mut Vec<usize>,
44        col_index: usize,
45        ascending: bool,
46    ) -> Result<(), TableError> {
47        // Collect string values for all rows at `col_index`
48        let mut values = Vec::with_capacity(self.row_count());
49        self.for_all_rows(&mut |row| {
50            let val = row
51                .get(col_index)
52                .map(|(v, _)| v.to_compact_string())
53                .unwrap_or_default();
54            values.push(val);
55            Ok(())
56        })?;
57
58        // Sort active_rows using the collected values
59        active_rows.sort_by(|&a, &b| {
60            let val_a = values.get(a);
61            let val_b = values.get(b);
62            if ascending {
63                val_a.cmp(&val_b)
64            } else {
65                val_b.cmp(&val_a)
66            }
67        });
68
69        Ok(())
70    }
71
72    /// Filters all rows sequentially. Override this to implement custom parallel filtering (e.g. Rayon).
73    fn filter_rows(
74        &self,
75        state: &TableState,
76        filters: &[(usize, Filter)],
77    ) -> Result<Vec<usize>, TableError> {
78        if filters.is_empty() {
79            return Ok((0..self.row_count()).collect());
80        }
81
82        let mut passing_indices = Vec::with_capacity(self.row_count());
83        let mut row_idx = 0;
84
85        self.for_all_rows(&mut |row| {
86            let highlight = state.highlights.get_usize(row_idx);
87            let mut matches = true;
88
89            for &(col_idx, ref filter) in filters {
90                if let Some(cell) = row.get(col_idx) {
91                    if !filter.matches(&cell.0, highlight) {
92                        matches = false;
93                        break;
94                    }
95                } else {
96                    matches = false;
97                    break;
98                }
99            }
100
101            if matches {
102                passing_indices.push(row_idx);
103            }
104            row_idx += 1;
105            Ok(())
106        })?;
107
108        Ok(passing_indices)
109    }
110
111    /// Returns tree nesting parameters for a given row.
112    /// Evaluates to `None` by default (representing traditional non-hierarchical flat tables).
113    fn row_hierarchy(&self, _state: &TableState, _row_index: usize) -> Option<RowHierarchy> {
114        None
115    }
116
117    /// Returns whether this provider represents a hierarchical tree table.
118    /// Returns `false` by default.
119    fn is_tree(&self) -> bool {
120        false
121    }
122
123    /// Returns the active parent row index for a given row (if any).
124    fn row_parent(&self, _row_index: usize) -> Option<usize> {
125        None
126    }
127
128    /// Returns the child row indices nested immediately under the specified row.
129    fn row_children(&self, _row_index: usize) -> Vec<usize> {
130        Vec::new()
131    }
132
133    /// Returns whether an individual row matches the currently active column filters.
134    fn row_matches(
135        &self,
136        _state: &TableState,
137        _row_index: usize,
138        _filters: &[(usize, Filter)],
139        _highlight: Option<u8>,
140    ) -> bool {
141        true
142    }
143}
144
145impl dyn TableProvider + '_ {
146    /// Maps over each selected row with a closure and collects the results into a flat Vector.
147    pub fn map_selected_rows<T, F>(
148        &self,
149        state: &TableState,
150        mut f: F,
151    ) -> Result<Vec<T>, TableError>
152    where
153        F: FnMut(RowSlice<'_, '_>) -> Result<T, TableError>,
154    {
155        let mut results = Vec::with_capacity(state.selected_rows.len() as usize);
156        self.for_selected_rows(state, &mut |row| {
157            results.push(f(row)?);
158            Ok(())
159        })?;
160        Ok(results)
161    }
162
163    /// Maps only the first selected row (if any) and returns the result, stopping iteration immediately.
164    pub fn map_first_selected_row<T, F>(
165        &self,
166        state: &TableState,
167        f: F,
168    ) -> Result<Option<T>, TableError>
169    where
170        F: FnOnce(RowSlice<'_, '_>) -> Result<T, TableError>,
171    {
172        let mut result = None;
173        let mut f_opt = Some(f);
174
175        self.for_selected_rows(state, &mut |row| {
176            if let Some(f_once) = f_opt.take() {
177                result = Some(f_once(row)?);
178            }
179            Ok(())
180        })?;
181
182        Ok(result)
183    }
184}
185
186pub trait RowSliceExt {
187    /// Extracts the primary text at the specified column index.
188    fn get_primary(&self, col_index: usize) -> Result<&str, TableError>;
189
190    /// Extracts the hover/alternate text at the specified column index.
191    fn get_hover(&self, col_index: usize) -> Result<&str, TableError>;
192
193    /// Parses the primary text at the specified column index into type `T`.
194    fn parse_primary<T>(&self, col_index: usize) -> Result<T, TableError>
195    where
196        T: std::str::FromStr,
197        <T as std::str::FromStr>::Err: std::fmt::Display;
198
199    /// Parses the hover text at the specified column index into type `T`.
200    fn parse_hover<T>(&self, col_index: usize) -> Result<T, TableError>
201    where
202        T: std::str::FromStr,
203        <T as std::str::FromStr>::Err: std::fmt::Display;
204}
205
206impl RowSliceExt for RowSlice<'_, '_> {
207    fn get_primary(&self, col_index: usize) -> Result<&str, TableError> {
208        self.get(col_index)
209            .map(|(val, _)| val.as_ref())
210            .ok_or(TableError::CorruptedState)
211    }
212
213    fn get_hover(&self, col_index: usize) -> Result<&str, TableError> {
214        self.get(col_index)
215            .and_then(|(_, hover)| hover.as_ref().map(AsRef::as_ref))
216            .ok_or(TableError::CorruptedState)
217    }
218
219    fn parse_primary<T>(&self, col_index: usize) -> Result<T, TableError>
220    where
221        T: std::str::FromStr,
222        <T as std::str::FromStr>::Err: std::fmt::Display,
223    {
224        T::from_str(self.get_primary(col_index)?).map_err(|e| TableError::Generic(e.to_string()))
225    }
226
227    fn parse_hover<T>(&self, col_index: usize) -> Result<T, TableError>
228    where
229        T: std::str::FromStr,
230        <T as std::str::FromStr>::Err: std::fmt::Display,
231    {
232        T::from_str(self.get_hover(col_index)?).map_err(|e| TableError::Generic(e.to_string()))
233    }
234}
235
236pub struct OperationContext<'a, 'b> {
237    pub ui: &'a mut egui::Ui,
238    pub data: &'a mut TableState,
239    pub provider: &'b dyn TableProvider,
240}
241
242#[derive(Debug, Default)]
243pub struct TableOperations(pub Vec<Vec<Box<dyn TableOperation>>>);
244
245impl TableOperations {
246    #[must_use]
247    pub fn new() -> Self {
248        Self::default()
249    }
250
251    #[must_use]
252    pub fn with_group(mut self, group: Vec<Box<dyn TableOperation>>) -> Self {
253        self.0.push(group);
254        self
255    }
256
257    #[must_use]
258    pub fn with_operation(mut self, op: impl TableOperation + 'static) -> Self {
259        if let Some(group) = self.0.last_mut() {
260            group.push(Box::new(op));
261        } else {
262            self.0.push(vec![Box::new(op)]);
263        }
264        self
265    }
266    /// Renders standard table operation buttons with default look.
267    pub fn gui(
268        &mut self,
269        ui: &mut egui::Ui,
270        provider: &dyn TableProvider,
271        data: &mut TableState,
272        context_menu: bool,
273    ) -> Result<bool, TableError> {
274        self.gui_custom(
275            ui,
276            provider,
277            data,
278            context_menu,
279            |ui, op, enabled, reason, context_menu| {
280                ui.add_enabled_ui(enabled, |ui| {
281                    let mut button = ui
282                        .button(op.get_name(context_menu).as_ref())
283                        .on_hover_text(op.name());
284                    if !enabled {
285                        button = button.on_disabled_hover_text(format!("{}\n{reason}", op.name()));
286                    }
287                    button
288                })
289                .inner
290            },
291        )
292    }
293    /// Renders table operations using a custom button builder callback.
294    ///
295    /// This handles all the state machine details (polling, execution, pending modes, group separation)
296    /// but allows full control over the visual presentation of each button.
297    pub fn gui_custom<F>(
298        &mut self,
299        ui: &mut egui::Ui,
300        provider: &dyn TableProvider,
301        data: &mut TableState,
302        context_menu: bool,
303        mut button_renderer: F,
304    ) -> Result<bool, TableError>
305    where
306        F: FnMut(
307            &mut egui::Ui,
308            &mut Box<dyn TableOperation>,
309            bool, // enabled
310            &str, // localized disabled reason
311            bool, // context_menu
312        ) -> egui::Response,
313    {
314        let mut refresh = false;
315        let mut any_clicked = false;
316        let num_groups = self.0.len();
317        for (g_idx, op_group) in self.0.iter_mut().enumerate() {
318            for op in op_group {
319                let is_pending = op.is_pending();
320                if op.just_completed() && op.refresh_on_completion() {
321                    refresh = true;
322                }
323                if op.pollable() {
324                    op.poll(ui, data)?;
325                }
326                let (enabled, reason) = if is_pending {
327                    (false, t!("operation-pending"))
328                } else {
329                    op.evaluate_enablement(data)
330                };
331                if !context_menu {
332                    op.extra_ui(ui, data)?;
333                }
334                let response = button_renderer(ui, op, enabled, reason.as_ref(), context_menu);
335                if response.clicked() {
336                    any_clicked = true;
337                    let mut ctx = OperationContext { ui, data, provider };
338                    op.exec(&mut ctx)?;
339                }
340            }
341            // Draw group separators in standard toolbar view (non-context-menu)
342            if !context_menu && g_idx + 1 < num_groups {
343                ui.separator();
344            }
345        }
346        if any_clicked && context_menu {
347            ui.close_kind(egui::UiKind::Menu);
348        }
349        Ok(refresh)
350    }
351}
352
353#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
354pub enum TableOperationEnablement {
355    #[default]
356    Always,
357    AtLeastOneFiltered,
358    AtLeastOneSelected,
359    OneSelected,
360}
361
362pub trait TableOperation: std::any::Any + std::fmt::Debug + Send + Sync {
363    fn name(&self) -> Cow<'_, str>;
364    fn icon(&self) -> &'static str {
365        "X"
366    }
367    fn get_name(&self, full: bool) -> Cow<'_, str> {
368        if full {
369            Cow::Owned(format!("{} {}", self.name(), self.icon()))
370        } else {
371            Cow::Borrowed(self.icon())
372        }
373    }
374    fn refresh_on_completion(&self) -> bool {
375        false
376    }
377    fn pollable(&self) -> bool {
378        false
379    }
380    fn is_first_page(&self) -> bool {
381        true
382    }
383    fn is_last_page(&self) -> bool {
384        true
385    }
386    fn enabled(&self) -> TableOperationEnablement;
387    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError>;
388    fn extra_ui(&mut self, _ui: &mut egui::Ui, _data: &mut TableState) -> Result<(), TableError> {
389        Ok(())
390    }
391    fn is_pending(&mut self) -> bool {
392        false
393    }
394    fn just_completed(&mut self) -> bool {
395        false
396    }
397    /// Routine tick loop, natively fired if `pollable()` evaluates to true.
398    fn poll(&mut self, _ui: &mut egui::Ui, _data: &mut TableState) -> Result<(), TableError> {
399        Ok(())
400    }
401    fn consume(&mut self) -> Result<(), TableError> {
402        Ok(())
403    }
404    fn error(&self) -> Option<&str> {
405        None
406    }
407    fn clear_error(&mut self) {}
408    fn is_modal_open(&self) -> bool {
409        false
410    }
411    fn set_modal_open(&mut self, _open: bool) {}
412    fn reset(&mut self) {}
413
414    fn pollable_modal(
415        &mut self,
416        ui: &mut egui::Ui,
417        centered: bool,
418        action: Cow<'_, str>,
419        action_progressive: Cow<'_, str>,
420        input_ui: impl FnOnce(&mut egui::Ui, &mut Self) -> Result<(), TableError>,
421    ) -> Result<(), TableError>
422    where
423        Self: Sized,
424    {
425        if self.is_modal_open() {
426            egui::Modal::new(ui.id().with("pollable_modal"))
427                .show(ui.ctx(), |ui| {
428                    ui.scope_builder(
429                        egui::UiBuilder::new().layout(egui::Layout::top_down(if centered {
430                            egui::Align::Center
431                        } else {
432                            egui::Align::Min
433                        })),
434                        |ui| {
435                            ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
436                            ui.heading(
437                                egui::RichText::new(format!("{} {}", self.name(), self.icon()))
438                                    .strong(),
439                            );
440                            ui.separator();
441                            ui.spacing_mut().item_spacing.y = 5.0;
442
443                            if self.just_completed() && self.error().is_none() {
444                                self.reset();
445                                return Ok(());
446                            }
447
448                            let is_pending = self.is_pending();
449                            ui.add_enabled_ui(!is_pending, |ui| input_ui(ui, self))
450                                .inner?;
451                            ui.add_space(10.0);
452
453                            if let Some(error) = self.error() {
454                                ui.colored_label(egui::Color32::RED, t!("error"));
455                                ui.colored_label(egui::Color32::RED, error);
456                            }
457
458                            if is_pending {
459                                ui.label(action_progressive);
460                                ui.add_space(5.0);
461                                ui.spinner();
462                            } else {
463                                if self.is_last_page() {
464                                    let is_allowed = self.poll_allow_execution();
465                                    if ui
466                                        .add_enabled(is_allowed, egui::Button::new(action))
467                                        .clicked()
468                                    {
469                                        self.clear_error();
470                                        self.consume()?;
471                                    }
472                                }
473                                if self.is_first_page() && ui.button(t!("cancel")).clicked() {
474                                    self.reset();
475                                }
476                            }
477                            Ok(())
478                        },
479                    )
480                    .inner
481                })
482                .inner
483        } else {
484            Ok(())
485        }
486    }
487
488    fn polled_modal(
489        &mut self,
490        ui: &mut egui::Ui,
491        heading: Cow<'_, str>,
492        action_progressive: Cow<'_, str>,
493        input_ui: impl FnOnce(&mut egui::Ui, &mut Self) -> Result<(), TableError>,
494    ) -> Result<(), TableError>
495    where
496        Self: Sized,
497    {
498        if self.is_modal_open() {
499            egui::Modal::new(ui.id().with("polled_modal"))
500                .show(ui.ctx(), |ui| {
501                    ui.vertical_centered(|ui| {
502                        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
503                        ui.heading(heading);
504                        ui.separator();
505                        ui.spacing_mut().item_spacing.y = 5.0;
506
507                        if self.is_pending() {
508                            ui.label(action_progressive);
509                            ui.add_space(5.0);
510                            ui.spinner();
511                        } else if let Some(error) = self.error() {
512                            ui.colored_label(egui::Color32::RED, t!("error"));
513                            ui.colored_label(egui::Color32::RED, error);
514                        } else {
515                            input_ui(ui, self)?;
516                        }
517
518                        ui.add_space(10.0);
519                        if ui.button(t!("close")).clicked() {
520                            self.reset();
521                        }
522                        Ok::<_, TableError>(())
523                    })
524                })
525                .inner
526                .inner?;
527        }
528        Ok(())
529    }
530
531    fn poll_allow_execution(&self) -> bool {
532        true
533    }
534
535    /// Evaluates if the operation is enabled based on the current `TableState`,
536    /// returning a tuple of `(is_enabled, localized_disabled_reason)`.
537    fn evaluate_enablement(&self, state: &TableState) -> (bool, Cow<'static, str>) {
538        match self.enabled() {
539            TableOperationEnablement::Always => (true, Cow::Borrowed("")),
540            TableOperationEnablement::AtLeastOneSelected => (
541                !state.selected_rows.is_empty(),
542                t!("operation-at-least-one"),
543            ),
544            TableOperationEnablement::OneSelected => {
545                (state.selected_rows.len() == 1, t!("operation-one"))
546            }
547            TableOperationEnablement::AtLeastOneFiltered => (
548                !state.active_rows.is_empty(),
549                t!("operation-at-least-one-filtered"),
550            ),
551        }
552    }
553}
554
555// Default Operations
556
557#[derive(Debug, Default)]
558pub struct CopyRows {
559    pub prioritize_hovers: bool,
560}
561
562impl TableOperation for CopyRows {
563    fn name(&self) -> Cow<'_, str> {
564        if self.prioritize_hovers {
565            t!("copy-hovered-rows")
566        } else {
567            t!("copy-rows")
568        }
569    }
570    fn icon(&self) -> &'static str {
571        if self.prioritize_hovers {
572            "📁"
573        } else {
574            "📋"
575        }
576    }
577    fn enabled(&self) -> TableOperationEnablement {
578        TableOperationEnablement::AtLeastOneSelected
579    }
580    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
581        // Pre-allocate a default chunk size to minimize system allocator pressure
582        let mut output = String::with_capacity(2048);
583
584        ctx.provider.for_selected_rows(ctx.data, &mut |row| {
585            if !output.is_empty() {
586                output.push('\n');
587            }
588            for (i, (val, hover)) in row.iter().enumerate() {
589                if i > 0 {
590                    output.push(',');
591                }
592                let cell_text = if self.prioritize_hovers {
593                    hover.as_deref().unwrap_or(val)
594                } else {
595                    val
596                };
597                output.push_str(cell_text);
598            }
599            Ok(())
600        })?;
601
602        ctx.ui.ctx().copy_text(output);
603        Ok(())
604    }
605    fn just_completed(&mut self) -> bool {
606        true
607    }
608}
609
610#[derive(Debug, Default)]
611pub struct CopyHeadersRows {
612    pub prioritize_hovers: bool,
613}
614
615impl TableOperation for CopyHeadersRows {
616    fn name(&self) -> Cow<'_, str> {
617        if self.prioritize_hovers {
618            t!("copy-hovered-rows-with-headers")
619        } else {
620            t!("copy-rows-with-headers")
621        }
622    }
623    fn icon(&self) -> &'static str {
624        if self.prioritize_hovers {
625            "🗄"
626        } else {
627            "📜"
628        }
629    }
630    fn enabled(&self) -> TableOperationEnablement {
631        TableOperationEnablement::AtLeastOneSelected
632    }
633
634    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
635        let headers = ctx.provider.headers();
636
637        // Pre-allocate a reasonable capacity for headers and initial rows
638        let mut output = String::with_capacity(2048);
639
640        // 1. Write the headers directly into the buffer (replacing headers.join(","))
641        for (i, header) in headers.iter().enumerate() {
642            if i > 0 {
643                output.push(',');
644            }
645            output.push_str(header);
646        }
647
648        // 2. Stream the selected rows sequentially into the same buffer
649        ctx.provider.for_selected_rows(ctx.data, &mut |row| {
650            output.push('\n');
651            for (i, (val, hover)) in row.iter().enumerate() {
652                if i > 0 {
653                    output.push(',');
654                }
655                let cell_text = if self.prioritize_hovers {
656                    hover.as_deref().unwrap_or(val)
657                } else {
658                    val
659                };
660                output.push_str(cell_text);
661            }
662            Ok(())
663        })?;
664
665        // 3. Send the single allocated string to the clip board
666        ctx.ui.ctx().copy_text(output);
667        Ok(())
668    }
669
670    fn just_completed(&mut self) -> bool {
671        true
672    }
673}
674
675#[derive(Debug, Default)]
676pub struct FilterSelectAll;
677
678impl TableOperation for FilterSelectAll {
679    fn name(&self) -> Cow<'_, str> {
680        t!("select-filtered")
681    }
682    fn icon(&self) -> &'static str {
683        "☑"
684    }
685    fn enabled(&self) -> TableOperationEnablement {
686        TableOperationEnablement::Always
687    }
688    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
689        let active_u32_iter = ctx.data.active_rows.iter().map(|&row| row as u32);
690        ctx.data.selected_rows.extend(active_u32_iter);
691        Ok(())
692    }
693    fn just_completed(&mut self) -> bool {
694        true
695    }
696}
697
698#[derive(Debug, Default)]
699pub struct FilterDeSelectAll;
700
701impl TableOperation for FilterDeSelectAll {
702    fn name(&self) -> Cow<'_, str> {
703        t!("deselect-filtered")
704    }
705    fn icon(&self) -> &'static str {
706        "❎"
707    }
708    fn enabled(&self) -> TableOperationEnablement {
709        TableOperationEnablement::Always
710    }
711    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
712        ctx.data.active_rows.iter().for_each(|row| {
713            ctx.data.selected_rows.remove(*row as u32);
714        });
715        Ok(())
716    }
717    fn just_completed(&mut self) -> bool {
718        true
719    }
720}
721
722#[derive(Debug, Default)]
723pub struct SelectAll;
724
725impl TableOperation for SelectAll {
726    fn name(&self) -> Cow<'_, str> {
727        t!("select-all")
728    }
729    fn icon(&self) -> &'static str {
730        "✔"
731    }
732    fn enabled(&self) -> TableOperationEnablement {
733        TableOperationEnablement::Always
734    }
735    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
736        ctx.data.selected_rows.clear();
737        ctx.data
738            .selected_rows
739            .insert_range(0..ctx.provider.row_count() as u32);
740        Ok(())
741    }
742    fn just_completed(&mut self) -> bool {
743        true
744    }
745}
746
747#[derive(Debug, Default)]
748pub struct DeSelectAll;
749
750impl TableOperation for DeSelectAll {
751    fn name(&self) -> Cow<'_, str> {
752        t!("deselect-all")
753    }
754    fn icon(&self) -> &'static str {
755        "❌"
756    }
757    fn enabled(&self) -> TableOperationEnablement {
758        TableOperationEnablement::Always
759    }
760    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
761        ctx.data.selected_rows.clear();
762        Ok(())
763    }
764    fn just_completed(&mut self) -> bool {
765        true
766    }
767}