Skip to main content

egui_table_kit/
operations.rs

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