egui-table-kit 0.1.0

An extension for `egui` that brings batteries-included, filtering, highlighting, tree structures, and an action dispatch to your tables.
Documentation
use std::borrow::Cow;

use eframe::egui;
use egui_table_kit::{
    error::TableError,
    filter::highlight::select_color,
    header::HeaderTrait,
    operations::{
        CopyRows, DeSelectAll, OperationContext, RowCallback, SelectAll, TableOperation,
        TableOperationEnablement, TableOperations, TableProvider,
    },
    state::TableState,
};

#[derive(Debug, Default)]
pub struct TagSelected {
    pub color_index: u8,
}

impl TableOperation for TagSelected {
    fn new() -> Self {
        Self { color_index: 0 }
    }
    fn name(&self) -> Cow<'_, str> {
        Cow::Borrowed("Tag Selected")
    }
    fn icon(&self) -> &'static str {
        "🏷"
    }
    fn enabled(&self) -> TableOperationEnablement {
        TableOperationEnablement::AtLeastOneSelected
    }
    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
        for row_idx in &ctx.data.selected_rows {
            ctx.data.highlights.insert(self.color_index, row_idx);
        }
        ctx.data.highlights_changed = true;
        Ok(())
    }
    fn just_completed(&mut self) -> bool {
        true
    }
}

#[derive(Debug, Default)]
pub struct UntagSelected;

impl TableOperation for UntagSelected {
    fn new() -> Self {
        Self
    }
    fn name(&self) -> Cow<'_, str> {
        Cow::Borrowed("Untag Selected")
    }
    fn icon(&self) -> &'static str {
        "🚫"
    }
    fn enabled(&self) -> TableOperationEnablement {
        TableOperationEnablement::AtLeastOneSelected
    }
    fn exec(&mut self, ctx: &mut OperationContext<'_, '_>) -> Result<(), TableError> {
        ctx.data.highlights.remove_map(&ctx.data.selected_rows);
        ctx.data.highlights_changed = true;
        Ok(())
    }
    fn just_completed(&mut self) -> bool {
        true
    }
}

struct ContactDataset {
    headers: Vec<&'static str>,
    records: Vec<Vec<String>>,
}

impl TableProvider for ContactDataset {
    fn headers(&self) -> &[&str] {
        &self.headers
    }

    fn row_count(&self) -> usize {
        self.records.len()
    }

    fn for_all_rows(&self, f: &mut RowCallback<'_>) -> Result<(), TableError> {
        for record in &self.records {
            let cells: Vec<(Cow<'_, str>, Option<Cow<'_, str>>)> = record
                .iter()
                .map(|val| (Cow::Borrowed(val.as_str()), None))
                .collect();
            f(&cells)?;
        }
        Ok(())
    }

    fn for_selected_rows(
        &self,
        state: &TableState,
        f: &mut RowCallback<'_>,
    ) -> Result<(), TableError> {
        for idx in &state.selected_rows {
            if let Some(record) = self.records.get(idx as usize) {
                let cells: Vec<(Cow<'_, str>, Option<Cow<'_, str>>)> = record
                    .iter()
                    .map(|val| (Cow::Borrowed(val.as_str()), None))
                    .collect();
                f(&cells)?;
            }
        }
        Ok(())
    }
}

struct TableApp {
    provider: ContactDataset,
    state: TableState,
    operations: TableOperations,
}

impl Default for TableApp {
    fn default() -> Self {
        let provider = ContactDataset {
            headers: vec!["Name", "Role", "Status"],
            records: vec![
                vec![
                    "Alice".to_string(),
                    "Engineer".to_string(),
                    "Active".to_string(),
                ],
                vec![
                    "Bob".to_string(),
                    "Designer".to_string(),
                    "Away".to_string(),
                ],
                vec![
                    "Charlie".to_string(),
                    "Manager".to_string(),
                    "Active".to_string(),
                ],
            ],
        };

        let row_count = provider.row_count();
        let state = TableState::new("demo_table_id", row_count);

        // Group operations to establish separators
        let operations = TableOperations::new()
            .with_group(vec![
                Box::new(SelectAll::new()),
                Box::new(DeSelectAll::new()),
            ])
            .with_group(vec![
                Box::new(TagSelected::new()),
                Box::new(UntagSelected::new()),
            ])
            .with_group(vec![Box::new(CopyRows::new())]);

        Self {
            provider,
            state,
            operations,
        }
    }
}

impl eframe::App for TableApp {
    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show_inside(ui, |ui| {
            ui.heading(egui::RichText::new("egui-table-kit Explorer").strong());
            ui.add_space(10.0);

            // Grouped action toolbar
            ui.horizontal(|ui| {
                let _ = self
                    .operations
                    .gui(ui, &self.provider, &mut self.state, false);
            });

            ui.add_space(4.0);
            ui.horizontal(|ui| {
                ui.label(self.state.counts_header(self.provider.row_count()));
                ui.weak("|  💡 Left-click headers for sorts, right-click headers for filters.");
            });
            ui.add_space(6.0);

            let org_colors = [[219, 58, 58], [58, 219, 112]];
            let user_colors = [];

            let row_height = 18.0; // Reduced row vertical spacing

            let builder = egui_extras::TableBuilder::new(ui)
                .id_salt("stable_explorer_table")
                .sense(egui::Sense::click())
                .striped(true)
                .resizable(true)
                .column(egui_extras::Column::initial(140.0))
                .column(egui_extras::Column::initial(110.0))
                .column(egui_extras::Column::remainder());

            if let Ok((responses, table)) = builder.archived_headers(
                &self.state,
                self.provider.headers().iter().copied(),
                18.0, // Reduced header vertical height
                &org_colors,
                &user_colors,
            ) {
                let _ = self.state.process_responses(&self.provider, responses);

                // Explicitly run column filters
                let filter_state = self.state.get_filter_state();
                let _ = self.state.apply_all_filters(&self.provider, &filter_state);

                // Explicitly run active sorting configuration
                if let Some((sort_col, sort_up)) = self.state.get_sort_state() {
                    let _ = self.provider.sort_active_rows(
                        &mut self.state.active_rows,
                        sort_col,
                        sort_up,
                    );
                }

                let active_rows = self.state.active_rows.clone();

                table.body(|mut body| {
                    for &row_idx in &active_rows {
                        let is_selected = self.state.selected_rows.contains(row_idx as u32);

                        let tag_color = if let Some(color_idx) =
                            self.state.highlights.get_usize(row_idx)
                            && let Ok(rgb) = select_color(color_idx, &org_colors, &user_colors)
                        {
                            Some(egui::Color32::from_rgb(rgb[0], rgb[1], rgb[2]))
                        } else {
                            None
                        };

                        body.row(row_height, |mut row| {
                            for col_idx in 0..self.provider.headers().len() {
                                row.col(|ui| {
                                    let item_spacing = ui.spacing().item_spacing;
                                    let gapless_rect = ui.max_rect().expand2(0.5 * item_spacing);

                                    let is_row_hovered =
                                        ui.ctx().pointer_latest_pos().is_some_and(|pos| {
                                            gapless_rect.y_range().contains(pos.y)
                                                && ui.clip_rect().x_range().contains(pos.x)
                                        });

                                    let response = ui.interact(
                                        gapless_rect,
                                        ui.id().with((row_idx, col_idx)),
                                        egui::Sense::click(),
                                    );

                                    if response.clicked() {
                                        self.state.handle_row_selection(
                                            ui.input(|i| i.modifiers),
                                            row_idx,
                                        );
                                    }

                                    let bg_color = if is_selected {
                                        Some(ui.visuals().selection.bg_fill)
                                    } else if is_row_hovered {
                                        Some(ui.visuals().widgets.hovered.weak_bg_fill)
                                    } else {
                                        None
                                    };

                                    if let Some(bg) = bg_color {
                                        ui.painter().rect_filled(
                                            gapless_rect,
                                            egui::CornerRadius::ZERO,
                                            bg,
                                        );
                                    }

                                    ui.horizontal(|ui| {
                                        ui.spacing_mut().item_spacing.x = 4.0;

                                        if col_idx == 0
                                            && let Some(color) = tag_color
                                        {
                                            let (rect, _) = ui.allocate_exact_size(
                                                egui::vec2(8.0, 8.0),
                                                egui::Sense::hover(),
                                            );
                                            ui.painter().rect_filled(rect, 1.5, color);
                                        }

                                        let cell_val = &self.provider.records[row_idx][col_idx];
                                        let text_color = if is_selected {
                                            ui.visuals().selection.stroke.color
                                        } else {
                                            ui.visuals().widgets.inactive.text_color()
                                        };
                                        ui.colored_label(text_color, cell_val);
                                    });
                                });
                            }
                        });
                    }
                });
            }
        });
    }
}

fn main() -> eframe::Result<()> {
    let options = eframe::NativeOptions::default();
    eframe::run_native(
        "egui-table-kit Explorer",
        options,
        Box::new(|_cc| Ok(Box::<TableApp>::default())),
    )
}