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
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        let mut refresh = false;
275        let mut any_clicked = false;
276
277        for op_group in &mut self.0 {
278            for op in op_group {
279                let is_pending = op.is_pending();
280                if op.just_completed() && op.refresh_on_completion() {
281                    refresh = true;
282                }
283
284                if op.pollable() {
285                    op.poll(ui, data)?;
286                }
287
288                let (enabled, reason): (bool, Cow<'static, str>) = if is_pending {
289                    (false, t!("operation-pending"))
290                } else {
291                    match op.enabled() {
292                        TableOperationEnablement::Always => (true, Cow::Borrowed("")),
293                        TableOperationEnablement::AtLeastOneSelected => {
294                            (!data.selected_rows.is_empty(), t!("operation-at-least-one"))
295                        }
296                        TableOperationEnablement::OneSelected => {
297                            (data.selected_rows.len() == 1, t!("operation-one"))
298                        }
299                        TableOperationEnablement::AtLeastOneFiltered => (
300                            !data.active_rows.is_empty(),
301                            t!("operation-at-least-one-filtered"),
302                        ),
303                    }
304                };
305
306                if !context_menu {
307                    op.extra_ui(ui, data)?;
308                }
309
310                ui.add_enabled_ui(enabled, |ui| {
311                    let mut button = ui
312                        .button(op.get_name(context_menu).as_ref())
313                        .on_hover_text(op.name());
314
315                    // Only format and allocate the error message string when disabled
316                    if !enabled {
317                        button = button.on_disabled_hover_text(format!("{}\n{reason}", op.name()));
318                    }
319
320                    if button.clicked() {
321                        any_clicked = true;
322                        let mut ctx = OperationContext { ui, data, provider };
323                        op.exec(&mut ctx)
324                    } else {
325                        Ok(())
326                    }
327                })
328                .inner?;
329            }
330            ui.separator();
331        }
332
333        if any_clicked && context_menu {
334            ui.close_kind(egui::UiKind::Menu);
335        }
336
337        Ok(refresh)
338    }
339}
340
341#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
342pub enum TableOperationEnablement {
343    #[default]
344    Always,
345    AtLeastOneFiltered,
346    AtLeastOneSelected,
347    OneSelected,
348}
349
350pub trait TableOperation: std::any::Any + std::fmt::Debug + Send + Sync {
351    fn name(&self) -> Cow<'_, str>;
352    fn icon(&self) -> &'static str {
353        "X"
354    }
355    fn get_name(&self, full: bool) -> Cow<'_, str> {
356        if full {
357            Cow::Owned(format!("{} {}", self.name(), self.icon()))
358        } else {
359            Cow::Borrowed(self.icon())
360        }
361    }
362    fn refresh_on_completion(&self) -> bool {
363        false
364    }
365    fn pollable(&self) -> bool {
366        false
367    }
368    fn is_first_page(&self) -> bool {
369        true
370    }
371    fn is_last_page(&self) -> bool {
372        true
373    }
374    fn enabled(&self) -> TableOperationEnablement;
375    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError>;
376    fn extra_ui(&mut self, _ui: &mut egui::Ui, _data: &mut TableState) -> Result<(), TableError> {
377        Ok(())
378    }
379    fn is_pending(&mut self) -> bool {
380        false
381    }
382    fn just_completed(&mut self) -> bool {
383        false
384    }
385    /// Routine tick loop, natively fired if `pollable()` evaluates to true.
386    fn poll(&mut self, _ui: &mut egui::Ui, _data: &mut TableState) -> Result<(), TableError> {
387        Ok(())
388    }
389    fn consume(&mut self) -> Result<(), TableError> {
390        Ok(())
391    }
392    fn error(&self) -> Option<&str> {
393        None
394    }
395    fn clear_error(&mut self) {}
396    fn is_modal_open(&self) -> bool {
397        false
398    }
399    fn set_modal_open(&mut self, _open: bool) {}
400    fn new() -> Self
401    where
402        Self: Sized;
403    fn reset(&mut self)
404    where
405        Self: Sized,
406    {
407        *self = Self::new();
408    }
409
410    fn pollable_modal(
411        &mut self,
412        ui: &mut egui::Ui,
413        centered: bool,
414        action: Cow<'_, str>,
415        action_progressive: Cow<'_, str>,
416        input_ui: impl FnOnce(&mut egui::Ui, &mut Self) -> Result<(), TableError>,
417    ) -> Result<(), TableError>
418    where
419        Self: Sized,
420    {
421        if self.is_modal_open() {
422            egui::Modal::new(ui.id().with("pollable_modal"))
423                .show(ui.ctx(), |ui| {
424                    ui.scope_builder(
425                        egui::UiBuilder::new().layout(egui::Layout::top_down(if centered {
426                            egui::Align::Center
427                        } else {
428                            egui::Align::Min
429                        })),
430                        |ui| {
431                            ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
432                            ui.heading(
433                                egui::RichText::new(format!("{} {}", self.name(), self.icon()))
434                                    .strong(),
435                            );
436                            ui.separator();
437                            ui.spacing_mut().item_spacing.y = 5.0;
438
439                            if self.just_completed() && self.error().is_none() {
440                                self.reset();
441                                return Ok(());
442                            }
443
444                            let is_pending = self.is_pending();
445                            ui.add_enabled_ui(!is_pending, |ui| input_ui(ui, self))
446                                .inner?;
447                            ui.add_space(10.0);
448
449                            if let Some(error) = self.error() {
450                                ui.colored_label(egui::Color32::RED, t!("error"));
451                                ui.colored_label(egui::Color32::RED, error);
452                            }
453
454                            if is_pending {
455                                ui.label(action_progressive);
456                                ui.add_space(5.0);
457                                ui.spinner();
458                            } else {
459                                if self.is_last_page() {
460                                    let is_allowed = self.poll_allow_execution();
461                                    if ui
462                                        .add_enabled(is_allowed, egui::Button::new(action))
463                                        .clicked()
464                                    {
465                                        self.clear_error();
466                                        self.consume()?;
467                                    }
468                                }
469                                if self.is_first_page() && ui.button(t!("cancel")).clicked() {
470                                    self.reset();
471                                }
472                            }
473                            Ok(())
474                        },
475                    )
476                    .inner
477                })
478                .inner
479        } else {
480            Ok(())
481        }
482    }
483
484    fn polled_modal(
485        &mut self,
486        ui: &mut egui::Ui,
487        heading: Cow<'_, str>,
488        action_progressive: Cow<'_, str>,
489        input_ui: impl FnOnce(&mut egui::Ui, &mut Self) -> Result<(), TableError>,
490    ) -> Result<(), TableError>
491    where
492        Self: Sized,
493    {
494        if self.is_modal_open() {
495            egui::Modal::new(ui.id().with("polled_modal"))
496                .show(ui.ctx(), |ui| {
497                    ui.vertical_centered(|ui| {
498                        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
499                        ui.heading(heading);
500                        ui.separator();
501                        ui.spacing_mut().item_spacing.y = 5.0;
502
503                        if self.is_pending() {
504                            ui.label(action_progressive);
505                            ui.add_space(5.0);
506                            ui.spinner();
507                        } else if let Some(error) = self.error() {
508                            ui.colored_label(egui::Color32::RED, t!("error"));
509                            ui.colored_label(egui::Color32::RED, error);
510                        } else {
511                            input_ui(ui, self)?;
512                        }
513
514                        ui.add_space(10.0);
515                        if ui.button(t!("close")).clicked() {
516                            self.reset();
517                        }
518                        Ok::<_, TableError>(())
519                    })
520                })
521                .inner
522                .inner?;
523        }
524        Ok(())
525    }
526
527    fn poll_allow_execution(&self) -> bool {
528        true
529    }
530}
531
532// Default Operations
533
534#[derive(Debug, Default)]
535pub struct CopyRows {
536    pub prioritize_hovers: bool,
537}
538
539impl TableOperation for CopyRows {
540    fn new() -> Self
541    where
542        Self: Sized,
543    {
544        Self::default()
545    }
546    fn name(&self) -> Cow<'_, str> {
547        if self.prioritize_hovers {
548            t!("copy-hovered-rows")
549        } else {
550            t!("copy-rows")
551        }
552    }
553    fn icon(&self) -> &'static str {
554        if self.prioritize_hovers {
555            "📁"
556        } else {
557            "📋"
558        }
559    }
560    fn enabled(&self) -> TableOperationEnablement {
561        TableOperationEnablement::AtLeastOneSelected
562    }
563    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
564        // Pre-allocate a default chunk size to minimize system allocator pressure
565        let mut output = String::with_capacity(2048);
566
567        ctx.provider.for_selected_rows(ctx.data, &mut |row| {
568            if !output.is_empty() {
569                output.push('\n');
570            }
571            for (i, (val, hover)) in row.iter().enumerate() {
572                if i > 0 {
573                    output.push(',');
574                }
575                let cell_text = if self.prioritize_hovers {
576                    hover.as_deref().unwrap_or(val)
577                } else {
578                    val
579                };
580                output.push_str(cell_text);
581            }
582            Ok(())
583        })?;
584
585        ctx.ui.ctx().copy_text(output);
586        Ok(())
587    }
588    fn just_completed(&mut self) -> bool {
589        true
590    }
591}
592
593#[derive(Debug, Default)]
594pub struct CopyHeadersRows {
595    pub prioritize_hovers: bool,
596}
597
598impl TableOperation for CopyHeadersRows {
599    fn new() -> Self
600    where
601        Self: Sized,
602    {
603        Self::default()
604    }
605    fn name(&self) -> Cow<'_, str> {
606        if self.prioritize_hovers {
607            t!("copy-hovered-rows-with-headers")
608        } else {
609            t!("copy-rows-with-headers")
610        }
611    }
612    fn icon(&self) -> &'static str {
613        if self.prioritize_hovers {
614            "🗄"
615        } else {
616            "📜"
617        }
618    }
619    fn enabled(&self) -> TableOperationEnablement {
620        TableOperationEnablement::AtLeastOneSelected
621    }
622
623    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
624        let headers = ctx.provider.headers();
625
626        // Pre-allocate a reasonable capacity for headers and initial rows
627        let mut output = String::with_capacity(2048);
628
629        // 1. Write the headers directly into the buffer (replacing headers.join(","))
630        for (i, header) in headers.iter().enumerate() {
631            if i > 0 {
632                output.push(',');
633            }
634            output.push_str(header);
635        }
636
637        // 2. Stream the selected rows sequentially into the same buffer
638        ctx.provider.for_selected_rows(ctx.data, &mut |row| {
639            output.push('\n');
640            for (i, (val, hover)) in row.iter().enumerate() {
641                if i > 0 {
642                    output.push(',');
643                }
644                let cell_text = if self.prioritize_hovers {
645                    hover.as_deref().unwrap_or(val)
646                } else {
647                    val
648                };
649                output.push_str(cell_text);
650            }
651            Ok(())
652        })?;
653
654        // 3. Send the single allocated string to the clip board
655        ctx.ui.ctx().copy_text(output);
656        Ok(())
657    }
658
659    fn just_completed(&mut self) -> bool {
660        true
661    }
662}
663
664#[derive(Debug, Default)]
665pub struct FilterSelectAll;
666
667impl TableOperation for FilterSelectAll {
668    fn new() -> Self
669    where
670        Self: Sized,
671    {
672        Self
673    }
674    fn name(&self) -> Cow<'_, str> {
675        t!("select-filtered")
676    }
677    fn icon(&self) -> &'static str {
678        "☑"
679    }
680    fn enabled(&self) -> TableOperationEnablement {
681        TableOperationEnablement::Always
682    }
683    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
684        let active_u32_iter = ctx.data.active_rows.iter().map(|&row| row as u32);
685        ctx.data.selected_rows.extend(active_u32_iter);
686        Ok(())
687    }
688    fn just_completed(&mut self) -> bool {
689        true
690    }
691}
692
693#[derive(Debug, Default)]
694pub struct FilterDeSelectAll;
695
696impl TableOperation for FilterDeSelectAll {
697    fn new() -> Self
698    where
699        Self: Sized,
700    {
701        Self
702    }
703    fn name(&self) -> Cow<'_, str> {
704        t!("deselect-filtered")
705    }
706    fn icon(&self) -> &'static str {
707        "❎"
708    }
709    fn enabled(&self) -> TableOperationEnablement {
710        TableOperationEnablement::Always
711    }
712    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
713        ctx.data.active_rows.iter().for_each(|row| {
714            ctx.data.selected_rows.remove(*row as u32);
715        });
716        Ok(())
717    }
718    fn just_completed(&mut self) -> bool {
719        true
720    }
721}
722
723#[derive(Debug, Default)]
724pub struct SelectAll;
725
726impl TableOperation for SelectAll {
727    fn new() -> Self
728    where
729        Self: Sized,
730    {
731        Self
732    }
733    fn name(&self) -> Cow<'_, str> {
734        t!("select-all")
735    }
736    fn icon(&self) -> &'static str {
737        "✔"
738    }
739    fn enabled(&self) -> TableOperationEnablement {
740        TableOperationEnablement::Always
741    }
742    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
743        ctx.data.selected_rows.clear();
744        ctx.data
745            .selected_rows
746            .insert_range(0..ctx.provider.row_count() as u32);
747        Ok(())
748    }
749    fn just_completed(&mut self) -> bool {
750        true
751    }
752}
753
754#[derive(Debug, Default)]
755pub struct DeSelectAll;
756
757impl TableOperation for DeSelectAll {
758    fn new() -> Self
759    where
760        Self: Sized,
761    {
762        Self
763    }
764    fn name(&self) -> Cow<'_, str> {
765        t!("deselect-all")
766    }
767    fn icon(&self) -> &'static str {
768        "❌"
769    }
770    fn enabled(&self) -> TableOperationEnablement {
771        TableOperationEnablement::Always
772    }
773    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
774        ctx.data.selected_rows.clear();
775        Ok(())
776    }
777    fn just_completed(&mut self) -> bool {
778        true
779    }
780}