Skip to main content

egui_table_kit/
operations.rs

1use std::{borrow::Cow, collections::HashSet};
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 {
244    pub groups: Vec<Vec<Box<dyn TableOperation>>>,
245    pub pending_tracker: HashSet<(usize, usize)>,
246    pub last_tick: u64,
247}
248
249impl TableOperations {
250    #[must_use]
251    pub fn new() -> Self {
252        Self::default()
253    }
254
255    #[must_use]
256    pub fn with_group(mut self, group: Vec<Box<dyn TableOperation>>) -> Self {
257        self.groups.push(group);
258        self
259    }
260
261    #[must_use]
262    pub fn with_operation(mut self, op: impl TableOperation + 'static) -> Self {
263        if let Some(group) = self.groups.last_mut() {
264            group.push(Box::new(op));
265        } else {
266            self.groups.push(vec![Box::new(op)]);
267        }
268        self
269    }
270
271    /// Evaluates state transitions exactly once per unique frame tick.
272    /// Returns `true` if any completed operation requested a view refresh.
273    pub fn update(&mut self, ctx: &egui::Context) -> bool {
274        let mut refresh = false;
275        let current_tick = ctx.cumulative_frame_nr();
276        if self.last_tick != current_tick {
277            self.last_tick = current_tick;
278
279            for (g_idx, op_group) in self.groups.iter_mut().enumerate() {
280                for (op_idx, op) in op_group.iter_mut().enumerate() {
281                    let key = (g_idx, op_idx);
282                    let pending = op.is_pending();
283                    let was_pending = self.pending_tracker.contains(&key);
284
285                    if was_pending && !pending {
286                        self.pending_tracker.remove(&key);
287                        let success = op.error().is_none();
288                        op.on_completed(success);
289                        if op.refresh_on_completion() {
290                            refresh = true;
291                        }
292                    } else if !was_pending && pending {
293                        self.pending_tracker.insert(key);
294                    }
295                }
296            }
297        }
298        refresh
299    }
300
301    /// Renders standard table operation buttons with default look.
302    pub fn gui(
303        &mut self,
304        ui: &mut egui::Ui,
305        provider: &dyn TableProvider,
306        data: &mut TableState,
307        context_menu: bool,
308    ) -> Result<bool, TableError> {
309        self.gui_custom(
310            ui,
311            provider,
312            data,
313            context_menu,
314            |ui, op, enabled, reason, context_menu| {
315                ui.add_enabled_ui(enabled, |ui| {
316                    let mut button = ui
317                        .button(op.get_name(context_menu).as_ref())
318                        .on_hover_text(op.name());
319                    if !enabled {
320                        button = button.on_disabled_hover_text(format!("{}\n{reason}", op.name()));
321                    }
322                    button
323                })
324                .inner
325            },
326        )
327    }
328
329    /// Renders table operations using a custom button builder callback.
330    ///
331    /// This handles all the state machine details (polling, execution, pending modes, group separation)
332    /// but allows full control over the visual presentation of each button.
333    pub fn gui_custom<F>(
334        &mut self,
335        ui: &mut egui::Ui,
336        provider: &dyn TableProvider,
337        data: &mut TableState,
338        context_menu: bool,
339        mut button_renderer: F,
340    ) -> Result<bool, TableError>
341    where
342        F: FnMut(
343            &mut egui::Ui,
344            &mut Box<dyn TableOperation>,
345            bool, // enabled
346            &str, // localized disabled reason
347            bool, // context_menu
348        ) -> egui::Response,
349    {
350        let refresh = self.update(ui.ctx());
351        let mut any_clicked = false;
352        let num_groups = self.groups.len();
353
354        // Render operations and process interactions
355        for (g_idx, op_group) in self.groups.iter_mut().enumerate() {
356            for op in op_group {
357                let is_pending = op.is_pending();
358
359                if op.pollable() {
360                    op.poll(ui, data)?;
361                }
362                let (enabled, reason) = if is_pending {
363                    (false, t!("operation-pending"))
364                } else {
365                    op.evaluate_enablement(data)
366                };
367                if !context_menu {
368                    op.extra_ui(ui, data)?;
369                }
370                let response = button_renderer(ui, op, enabled, reason.as_ref(), context_menu);
371                if response.clicked() {
372                    any_clicked = true;
373                    let mut ctx = OperationContext { ui, data, provider };
374                    op.exec(&mut ctx)?;
375                }
376            }
377            // Draw group separators in standard layouts and menus alike
378            if g_idx + 1 < num_groups {
379                ui.separator();
380            }
381        }
382        if any_clicked && context_menu {
383            ui.close_kind(egui::UiKind::Menu);
384        }
385        Ok(refresh)
386    }
387
388    /// Renders all operations in a specific group.
389    /// This is useful for building custom caller layouts, submenus, and advanced structural separations.
390    pub fn show_group<F>(
391        &mut self,
392        ui: &mut egui::Ui,
393        provider: &dyn TableProvider,
394        data: &mut TableState,
395        group_idx: usize,
396        context_menu: bool,
397        mut button_renderer: F,
398    ) -> Result<bool, TableError>
399    where
400        F: FnMut(
401            &mut egui::Ui,
402            &mut Box<dyn TableOperation>,
403            bool, // enabled
404            &str, // localized disabled reason
405        ) -> egui::Response,
406    {
407        if group_idx >= self.groups.len() {
408            return Ok(false);
409        }
410
411        let refresh = self.update(ui.ctx());
412        let mut any_clicked = false;
413
414        let op_group = &mut self.groups[group_idx];
415        for op in op_group {
416            let is_pending = op.is_pending();
417
418            if op.pollable() {
419                op.poll(ui, data)?;
420            }
421            let (enabled, reason) = if is_pending {
422                (false, t!("operation-pending"))
423            } else {
424                op.evaluate_enablement(data)
425            };
426
427            if !context_menu {
428                op.extra_ui(ui, data)?;
429            }
430
431            let response = button_renderer(ui, op, enabled, reason.as_ref());
432            if response.clicked() {
433                any_clicked = true;
434                let mut ctx = OperationContext { ui, data, provider };
435                op.exec(&mut ctx)?;
436            }
437        }
438
439        if any_clicked && context_menu {
440            ui.close_kind(egui::UiKind::Menu);
441        }
442
443        Ok(refresh)
444    }
445
446    /// Renders a single operation directly at a specific group and operation index.
447    /// Gives the caller total control over fine-grained placement and visual arrangement.
448    pub fn show_operation<F>(
449        &mut self,
450        ui: &mut egui::Ui,
451        provider: &dyn TableProvider,
452        data: &mut TableState,
453        group_idx: usize,
454        op_idx: usize,
455        context_menu: bool,
456        button_renderer: F,
457    ) -> Result<bool, TableError>
458    where
459        F: FnOnce(
460            &mut egui::Ui,
461            &mut Box<dyn TableOperation>,
462            bool, // enabled
463            &str, // localized disabled reason
464        ) -> egui::Response,
465    {
466        if group_idx >= self.groups.len() || op_idx >= self.groups[group_idx].len() {
467            return Ok(false);
468        }
469
470        let refresh = self.update(ui.ctx());
471
472        let op = &mut self.groups[group_idx][op_idx];
473        let is_pending = op.is_pending();
474
475        if op.pollable() {
476            op.poll(ui, data)?;
477        }
478        let (enabled, reason) = if is_pending {
479            (false, t!("operation-pending"))
480        } else {
481            op.evaluate_enablement(data)
482        };
483
484        if !context_menu {
485            op.extra_ui(ui, data)?;
486        }
487
488        let response = button_renderer(ui, op, enabled, reason.as_ref());
489        if response.clicked() {
490            let mut ctx = OperationContext { ui, data, provider };
491            op.exec(&mut ctx)?;
492            if context_menu {
493                ui.close_kind(egui::UiKind::Menu);
494            }
495        }
496
497        Ok(refresh)
498    }
499}
500
501#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
502pub enum TableOperationEnablement {
503    #[default]
504    Always,
505    AtLeastOneFiltered,
506    AtLeastOneSelected,
507    OneSelected,
508}
509
510pub trait TableOperation: std::any::Any + std::fmt::Debug + Send + Sync {
511    fn name(&self) -> Cow<'_, str>;
512    fn icon(&self) -> &'static str {
513        "X"
514    }
515    fn get_name(&self, full: bool) -> Cow<'_, str> {
516        if full {
517            Cow::Owned(format!("{} {}", self.name(), self.icon()))
518        } else {
519            Cow::Borrowed(self.icon())
520        }
521    }
522    fn refresh_on_completion(&self) -> bool {
523        false
524    }
525    fn pollable(&self) -> bool {
526        false
527    }
528    fn is_first_page(&self) -> bool {
529        true
530    }
531    fn is_last_page(&self) -> bool {
532        true
533    }
534    fn enabled(&self) -> TableOperationEnablement;
535    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError>;
536    fn extra_ui(&mut self, _ui: &mut egui::Ui, _data: &mut TableState) -> Result<(), TableError> {
537        Ok(())
538    }
539    fn is_pending(&mut self) -> bool {
540        false
541    }
542
543    /// Event hook called exactly once when the operation transitions from pending to completed.
544    fn on_completed(&mut self, _success: bool) {}
545
546    /// Routine tick loop, natively fired if `pollable()` evaluates to true.
547    fn poll(&mut self, _ui: &mut egui::Ui, _data: &mut TableState) -> Result<(), TableError> {
548        Ok(())
549    }
550    fn consume(&mut self) -> Result<(), TableError> {
551        Ok(())
552    }
553    fn error(&self) -> Option<&str> {
554        None
555    }
556    fn clear_error(&mut self) {}
557    fn is_modal_open(&self) -> bool {
558        false
559    }
560    fn set_modal_open(&mut self, _open: bool) {}
561    fn reset(&mut self) {}
562
563    fn pollable_modal(
564        &mut self,
565        ui: &mut egui::Ui,
566        centered: bool,
567        action: Cow<'_, str>,
568        action_progressive: Cow<'_, str>,
569        input_ui: impl FnOnce(&mut egui::Ui, &mut Self) -> Result<(), TableError>,
570    ) -> Result<(), TableError>
571    where
572        Self: Sized,
573    {
574        if self.is_modal_open() {
575            egui::Modal::new(ui.id().with("pollable_modal"))
576                .show(ui.ctx(), |ui| {
577                    ui.scope_builder(
578                        egui::UiBuilder::new().layout(egui::Layout::top_down(if centered {
579                            egui::Align::Center
580                        } else {
581                            egui::Align::Min
582                        })),
583                        |ui| {
584                            ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
585                            ui.heading(
586                                egui::RichText::new(format!("{} {}", self.name(), self.icon()))
587                                    .strong(),
588                            );
589                            ui.separator();
590                            ui.spacing_mut().item_spacing.y = 5.0;
591
592                            let is_pending = self.is_pending();
593                            ui.add_enabled_ui(!is_pending, |ui| input_ui(ui, self))
594                                .inner?;
595                            ui.add_space(10.0);
596
597                            if let Some(error) = self.error() {
598                                ui.colored_label(egui::Color32::RED, t!("error"));
599                                ui.colored_label(egui::Color32::RED, error);
600                            }
601
602                            if is_pending {
603                                ui.label(action_progressive);
604                                ui.add_space(5.0);
605                                ui.spinner();
606                            } else {
607                                if self.is_last_page() {
608                                    let is_allowed = self.poll_allow_execution();
609                                    if ui
610                                        .add_enabled(is_allowed, egui::Button::new(action))
611                                        .clicked()
612                                    {
613                                        self.clear_error();
614                                        self.consume()?;
615                                    }
616                                }
617                                if self.is_first_page() && ui.button(t!("cancel")).clicked() {
618                                    self.reset();
619                                }
620                            }
621                            Ok(())
622                        },
623                    )
624                    .inner
625                })
626                .inner
627        } else {
628            Ok(())
629        }
630    }
631
632    fn polled_modal(
633        &mut self,
634        ui: &mut egui::Ui,
635        heading: Cow<'_, str>,
636        action_progressive: Cow<'_, str>,
637        input_ui: impl FnOnce(&mut egui::Ui, &mut Self) -> Result<(), TableError>,
638    ) -> Result<(), TableError>
639    where
640        Self: Sized,
641    {
642        if self.is_modal_open() {
643            egui::Modal::new(ui.id().with("polled_modal"))
644                .show(ui.ctx(), |ui| {
645                    ui.vertical_centered(|ui| {
646                        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
647                        ui.heading(heading);
648                        ui.separator();
649                        ui.spacing_mut().item_spacing.y = 5.0;
650
651                        if self.is_pending() {
652                            ui.label(action_progressive);
653                            ui.add_space(5.0);
654                            ui.spinner();
655                        } else if let Some(error) = self.error() {
656                            ui.colored_label(egui::Color32::RED, t!("error"));
657                            ui.colored_label(egui::Color32::RED, error);
658                        } else {
659                            input_ui(ui, self)?;
660                        }
661
662                        ui.add_space(10.0);
663                        if ui.button(t!("close")).clicked() {
664                            self.reset();
665                        }
666                        Ok::<_, TableError>(())
667                    })
668                })
669                .inner
670                .inner?;
671        }
672        Ok(())
673    }
674
675    fn poll_allow_execution(&self) -> bool {
676        true
677    }
678
679    /// Evaluates if the operation is enabled based on the current `TableState`,
680    /// returning a tuple of `(is_enabled, localized_disabled_reason)`.
681    fn evaluate_enablement(&self, state: &TableState) -> (bool, Cow<'static, str>) {
682        match self.enabled() {
683            TableOperationEnablement::Always => (true, Cow::Borrowed("")),
684            TableOperationEnablement::AtLeastOneSelected => (
685                !state.selected_rows.is_empty(),
686                t!("operation-at-least-one"),
687            ),
688            TableOperationEnablement::OneSelected => {
689                (state.selected_rows.len() == 1, t!("operation-one"))
690            }
691            TableOperationEnablement::AtLeastOneFiltered => (
692                !state.active_rows.is_empty(),
693                t!("operation-at-least-one-filtered"),
694            ),
695        }
696    }
697}
698
699// Default Operations
700
701#[derive(Debug, Default)]
702pub struct CopyRows {
703    pub prioritize_hovers: bool,
704}
705
706impl TableOperation for CopyRows {
707    fn name(&self) -> Cow<'_, str> {
708        if self.prioritize_hovers {
709            t!("copy-hovered-rows")
710        } else {
711            t!("copy-rows")
712        }
713    }
714    fn icon(&self) -> &'static str {
715        if self.prioritize_hovers {
716            "📁"
717        } else {
718            "📋"
719        }
720    }
721    fn enabled(&self) -> TableOperationEnablement {
722        TableOperationEnablement::AtLeastOneSelected
723    }
724    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
725        // Pre-allocate a default chunk size to minimize system allocator pressure
726        let mut output = String::with_capacity(2048);
727
728        ctx.provider.for_selected_rows(ctx.data, &mut |row| {
729            if !output.is_empty() {
730                output.push('\n');
731            }
732            for (i, (val, hover)) in row.iter().enumerate() {
733                if i > 0 {
734                    output.push(',');
735                }
736                let cell_text = if self.prioritize_hovers {
737                    hover.as_deref().unwrap_or(val)
738                } else {
739                    val
740                };
741                output.push_str(cell_text);
742            }
743            Ok(())
744        })?;
745
746        ctx.ui.ctx().copy_text(output);
747        Ok(())
748    }
749}
750
751#[derive(Debug, Default)]
752pub struct CopyHeadersRows {
753    pub prioritize_hovers: bool,
754}
755
756impl TableOperation for CopyHeadersRows {
757    fn name(&self) -> Cow<'_, str> {
758        if self.prioritize_hovers {
759            t!("copy-hovered-rows-with-headers")
760        } else {
761            t!("copy-rows-with-headers")
762        }
763    }
764    fn icon(&self) -> &'static str {
765        if self.prioritize_hovers {
766            "🗄"
767        } else {
768            "📜"
769        }
770    }
771    fn enabled(&self) -> TableOperationEnablement {
772        TableOperationEnablement::AtLeastOneSelected
773    }
774
775    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
776        let headers = ctx.provider.headers();
777
778        // Pre-allocate a reasonable capacity for headers and initial rows
779        let mut output = String::with_capacity(2048);
780
781        // 1. Write the headers directly into the buffer (replacing headers.join(","))
782        for (i, header) in headers.iter().enumerate() {
783            if i > 0 {
784                output.push(',');
785            }
786            output.push_str(header);
787        }
788
789        // 2. Stream the selected rows sequentially into the same buffer
790        ctx.provider.for_selected_rows(ctx.data, &mut |row| {
791            output.push('\n');
792            for (i, (val, hover)) in row.iter().enumerate() {
793                if i > 0 {
794                    output.push(',');
795                }
796                let cell_text = if self.prioritize_hovers {
797                    hover.as_deref().unwrap_or(val)
798                } else {
799                    val
800                };
801                output.push_str(cell_text);
802            }
803            Ok(())
804        })?;
805
806        // 3. Send the single allocated string to the clipboard
807        ctx.ui.ctx().copy_text(output);
808        Ok(())
809    }
810}
811
812#[derive(Debug, Default)]
813pub struct FilterSelectAll;
814
815impl TableOperation for FilterSelectAll {
816    fn name(&self) -> Cow<'_, str> {
817        t!("select-filtered")
818    }
819    fn icon(&self) -> &'static str {
820        "☑"
821    }
822    fn enabled(&self) -> TableOperationEnablement {
823        TableOperationEnablement::Always
824    }
825    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
826        let active_u32_iter = ctx.data.active_rows.iter().map(|&row| row as u32);
827        ctx.data.selected_rows.extend(active_u32_iter);
828        Ok(())
829    }
830}
831
832#[derive(Debug, Default)]
833pub struct FilterDeSelectAll;
834
835impl TableOperation for FilterDeSelectAll {
836    fn name(&self) -> Cow<'_, str> {
837        t!("deselect-filtered")
838    }
839    fn icon(&self) -> &'static str {
840        "❎"
841    }
842    fn enabled(&self) -> TableOperationEnablement {
843        TableOperationEnablement::Always
844    }
845    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
846        ctx.data.active_rows.iter().for_each(|row| {
847            ctx.data.selected_rows.remove(*row as u32);
848        });
849        Ok(())
850    }
851}
852
853#[derive(Debug, Default)]
854pub struct SelectAll;
855
856impl TableOperation for SelectAll {
857    fn name(&self) -> Cow<'_, str> {
858        t!("select-all")
859    }
860    fn icon(&self) -> &'static str {
861        "✔"
862    }
863    fn enabled(&self) -> TableOperationEnablement {
864        TableOperationEnablement::Always
865    }
866    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
867        ctx.data.selected_rows.clear();
868        ctx.data
869            .selected_rows
870            .insert_range(0..ctx.provider.row_count() as u32);
871        Ok(())
872    }
873}
874
875#[derive(Debug, Default)]
876pub struct DeSelectAll;
877
878impl TableOperation for DeSelectAll {
879    fn name(&self) -> Cow<'_, str> {
880        t!("deselect-all")
881    }
882    fn icon(&self) -> &'static str {
883        "❌"
884    }
885    fn enabled(&self) -> TableOperationEnablement {
886        TableOperationEnablement::Always
887    }
888    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
889        ctx.data.selected_rows.clear();
890        Ok(())
891    }
892}