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 reset(&mut self) {}
401
402    fn pollable_modal(
403        &mut self,
404        ui: &mut egui::Ui,
405        centered: bool,
406        action: Cow<'_, str>,
407        action_progressive: Cow<'_, str>,
408        input_ui: impl FnOnce(&mut egui::Ui, &mut Self) -> Result<(), TableError>,
409    ) -> Result<(), TableError>
410    where
411        Self: Sized,
412    {
413        if self.is_modal_open() {
414            egui::Modal::new(ui.id().with("pollable_modal"))
415                .show(ui.ctx(), |ui| {
416                    ui.scope_builder(
417                        egui::UiBuilder::new().layout(egui::Layout::top_down(if centered {
418                            egui::Align::Center
419                        } else {
420                            egui::Align::Min
421                        })),
422                        |ui| {
423                            ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
424                            ui.heading(
425                                egui::RichText::new(format!("{} {}", self.name(), self.icon()))
426                                    .strong(),
427                            );
428                            ui.separator();
429                            ui.spacing_mut().item_spacing.y = 5.0;
430
431                            if self.just_completed() && self.error().is_none() {
432                                self.reset();
433                                return Ok(());
434                            }
435
436                            let is_pending = self.is_pending();
437                            ui.add_enabled_ui(!is_pending, |ui| input_ui(ui, self))
438                                .inner?;
439                            ui.add_space(10.0);
440
441                            if let Some(error) = self.error() {
442                                ui.colored_label(egui::Color32::RED, t!("error"));
443                                ui.colored_label(egui::Color32::RED, error);
444                            }
445
446                            if is_pending {
447                                ui.label(action_progressive);
448                                ui.add_space(5.0);
449                                ui.spinner();
450                            } else {
451                                if self.is_last_page() {
452                                    let is_allowed = self.poll_allow_execution();
453                                    if ui
454                                        .add_enabled(is_allowed, egui::Button::new(action))
455                                        .clicked()
456                                    {
457                                        self.clear_error();
458                                        self.consume()?;
459                                    }
460                                }
461                                if self.is_first_page() && ui.button(t!("cancel")).clicked() {
462                                    self.reset();
463                                }
464                            }
465                            Ok(())
466                        },
467                    )
468                    .inner
469                })
470                .inner
471        } else {
472            Ok(())
473        }
474    }
475
476    fn polled_modal(
477        &mut self,
478        ui: &mut egui::Ui,
479        heading: Cow<'_, str>,
480        action_progressive: Cow<'_, str>,
481        input_ui: impl FnOnce(&mut egui::Ui, &mut Self) -> Result<(), TableError>,
482    ) -> Result<(), TableError>
483    where
484        Self: Sized,
485    {
486        if self.is_modal_open() {
487            egui::Modal::new(ui.id().with("polled_modal"))
488                .show(ui.ctx(), |ui| {
489                    ui.vertical_centered(|ui| {
490                        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
491                        ui.heading(heading);
492                        ui.separator();
493                        ui.spacing_mut().item_spacing.y = 5.0;
494
495                        if self.is_pending() {
496                            ui.label(action_progressive);
497                            ui.add_space(5.0);
498                            ui.spinner();
499                        } else if let Some(error) = self.error() {
500                            ui.colored_label(egui::Color32::RED, t!("error"));
501                            ui.colored_label(egui::Color32::RED, error);
502                        } else {
503                            input_ui(ui, self)?;
504                        }
505
506                        ui.add_space(10.0);
507                        if ui.button(t!("close")).clicked() {
508                            self.reset();
509                        }
510                        Ok::<_, TableError>(())
511                    })
512                })
513                .inner
514                .inner?;
515        }
516        Ok(())
517    }
518
519    fn poll_allow_execution(&self) -> bool {
520        true
521    }
522}
523
524// Default Operations
525
526#[derive(Debug, Default)]
527pub struct CopyRows {
528    pub prioritize_hovers: bool,
529}
530
531impl TableOperation for CopyRows {
532    fn name(&self) -> Cow<'_, str> {
533        if self.prioritize_hovers {
534            t!("copy-hovered-rows")
535        } else {
536            t!("copy-rows")
537        }
538    }
539    fn icon(&self) -> &'static str {
540        if self.prioritize_hovers {
541            "📁"
542        } else {
543            "📋"
544        }
545    }
546    fn enabled(&self) -> TableOperationEnablement {
547        TableOperationEnablement::AtLeastOneSelected
548    }
549    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
550        // Pre-allocate a default chunk size to minimize system allocator pressure
551        let mut output = String::with_capacity(2048);
552
553        ctx.provider.for_selected_rows(ctx.data, &mut |row| {
554            if !output.is_empty() {
555                output.push('\n');
556            }
557            for (i, (val, hover)) in row.iter().enumerate() {
558                if i > 0 {
559                    output.push(',');
560                }
561                let cell_text = if self.prioritize_hovers {
562                    hover.as_deref().unwrap_or(val)
563                } else {
564                    val
565                };
566                output.push_str(cell_text);
567            }
568            Ok(())
569        })?;
570
571        ctx.ui.ctx().copy_text(output);
572        Ok(())
573    }
574    fn just_completed(&mut self) -> bool {
575        true
576    }
577}
578
579#[derive(Debug, Default)]
580pub struct CopyHeadersRows {
581    pub prioritize_hovers: bool,
582}
583
584impl TableOperation for CopyHeadersRows {
585    fn name(&self) -> Cow<'_, str> {
586        if self.prioritize_hovers {
587            t!("copy-hovered-rows-with-headers")
588        } else {
589            t!("copy-rows-with-headers")
590        }
591    }
592    fn icon(&self) -> &'static str {
593        if self.prioritize_hovers {
594            "🗄"
595        } else {
596            "📜"
597        }
598    }
599    fn enabled(&self) -> TableOperationEnablement {
600        TableOperationEnablement::AtLeastOneSelected
601    }
602
603    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
604        let headers = ctx.provider.headers();
605
606        // Pre-allocate a reasonable capacity for headers and initial rows
607        let mut output = String::with_capacity(2048);
608
609        // 1. Write the headers directly into the buffer (replacing headers.join(","))
610        for (i, header) in headers.iter().enumerate() {
611            if i > 0 {
612                output.push(',');
613            }
614            output.push_str(header);
615        }
616
617        // 2. Stream the selected rows sequentially into the same buffer
618        ctx.provider.for_selected_rows(ctx.data, &mut |row| {
619            output.push('\n');
620            for (i, (val, hover)) in row.iter().enumerate() {
621                if i > 0 {
622                    output.push(',');
623                }
624                let cell_text = if self.prioritize_hovers {
625                    hover.as_deref().unwrap_or(val)
626                } else {
627                    val
628                };
629                output.push_str(cell_text);
630            }
631            Ok(())
632        })?;
633
634        // 3. Send the single allocated string to the clip board
635        ctx.ui.ctx().copy_text(output);
636        Ok(())
637    }
638
639    fn just_completed(&mut self) -> bool {
640        true
641    }
642}
643
644#[derive(Debug, Default)]
645pub struct FilterSelectAll;
646
647impl TableOperation for FilterSelectAll {
648    fn name(&self) -> Cow<'_, str> {
649        t!("select-filtered")
650    }
651    fn icon(&self) -> &'static str {
652        "☑"
653    }
654    fn enabled(&self) -> TableOperationEnablement {
655        TableOperationEnablement::Always
656    }
657    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
658        let active_u32_iter = ctx.data.active_rows.iter().map(|&row| row as u32);
659        ctx.data.selected_rows.extend(active_u32_iter);
660        Ok(())
661    }
662    fn just_completed(&mut self) -> bool {
663        true
664    }
665}
666
667#[derive(Debug, Default)]
668pub struct FilterDeSelectAll;
669
670impl TableOperation for FilterDeSelectAll {
671    fn name(&self) -> Cow<'_, str> {
672        t!("deselect-filtered")
673    }
674    fn icon(&self) -> &'static str {
675        "❎"
676    }
677    fn enabled(&self) -> TableOperationEnablement {
678        TableOperationEnablement::Always
679    }
680    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
681        ctx.data.active_rows.iter().for_each(|row| {
682            ctx.data.selected_rows.remove(*row as u32);
683        });
684        Ok(())
685    }
686    fn just_completed(&mut self) -> bool {
687        true
688    }
689}
690
691#[derive(Debug, Default)]
692pub struct SelectAll;
693
694impl TableOperation for SelectAll {
695    fn name(&self) -> Cow<'_, str> {
696        t!("select-all")
697    }
698    fn icon(&self) -> &'static str {
699        "✔"
700    }
701    fn enabled(&self) -> TableOperationEnablement {
702        TableOperationEnablement::Always
703    }
704    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
705        ctx.data.selected_rows.clear();
706        ctx.data
707            .selected_rows
708            .insert_range(0..ctx.provider.row_count() as u32);
709        Ok(())
710    }
711    fn just_completed(&mut self) -> bool {
712        true
713    }
714}
715
716#[derive(Debug, Default)]
717pub struct DeSelectAll;
718
719impl TableOperation for DeSelectAll {
720    fn name(&self) -> Cow<'_, str> {
721        t!("deselect-all")
722    }
723    fn icon(&self) -> &'static str {
724        "❌"
725    }
726    fn enabled(&self) -> TableOperationEnablement {
727        TableOperationEnablement::Always
728    }
729    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
730        ctx.data.selected_rows.clear();
731        Ok(())
732    }
733    fn just_completed(&mut self) -> bool {
734        true
735    }
736}