Skip to main content

egui_table_kit/
state.rs

1//! Custom interactive view states mapped seamlessly to virtualization targets.
2
3use std::{collections::HashMap, fmt::Write as _, sync::Arc};
4
5use compact_str::CompactString;
6use fluent_zero::t;
7use roaring::RoaringBitmap;
8
9use crate::operations::Row as _;
10
11use super::{
12    error::TableError,
13    filter::Filter,
14    header::{ColResponse, ColumnState},
15    highlights::Highlights,
16    operations::{RowHierarchy, TableProvider},
17};
18
19#[derive(Debug)]
20pub enum FilterUpdate {
21    Added(usize, Filter),
22    Removed(usize),
23    Modified(usize, Filter),
24}
25
26#[derive(Debug)]
27pub struct TableChanges {
28    pub filter_update: Option<FilterUpdate>,
29    pub sort_update: Option<usize>,
30    pub filter_state: Vec<(usize, Filter)>,
31    pub sort_state: Option<(usize, bool)>,
32}
33
34/// Holds interactive visual properties for columns and selection structures.
35#[derive(Debug, Default)]
36pub struct TableState {
37    pub id: CompactString,
38    pub columns: Vec<ColumnState>,
39    pub highlights: Highlights,
40    pub highlights_changed: bool,
41    pub active_rows: Vec<usize>,
42    pub selected_rows: RoaringBitmap,
43    pub expanded_rows: RoaringBitmap,
44    pub last_clicked_visible_index: Option<usize>,
45    pub filter_matches: RoaringBitmap,
46    pub filter_cache_dirty: bool,
47    pub sorted_children_cache: HashMap<usize, Arc<Vec<usize>>, ahash::RandomState>,
48}
49
50impl TableState {
51    /// Constructs and initializes view settings.
52    #[must_use]
53    pub fn new(id: impl Into<CompactString>, row_count: usize) -> Self {
54        Self {
55            id: id.into(),
56            active_rows: (0..row_count).collect(),
57            filter_cache_dirty: true, // Mark dirty initially to force first-frame population
58            ..Default::default()
59        }
60    }
61
62    /// Accesses the active filter options defined for each column.
63    #[must_use]
64    pub fn get_filter_state(&self) -> Vec<(usize, Filter)> {
65        self.columns
66            .iter()
67            .enumerate()
68            .filter_map(|(col_index, col)| {
69                if col.response.filtering.is_empty() {
70                    None
71                } else {
72                    Some((col_index, col.response.filtering.clone()))
73                }
74            })
75            .collect()
76    }
77
78    /// Accesses active sorting criteria (column offset and sorting order).
79    #[must_use]
80    pub fn get_sort_state(&self) -> Option<(usize, bool)> {
81        self.columns
82            .iter()
83            .enumerate()
84            .find_map(|(col_index, col)| col.sort_up.map(|sort_up| (col_index, sort_up)))
85    }
86
87    /// Renders dynamic status headers detailing matching selection counts.
88    #[must_use]
89    pub fn counts_header(&self, row_len: usize) -> String {
90        let mut counts = String::with_capacity(64);
91        counts.push_str(&t!("total-rows", { "count" => row_len }));
92
93        let active_rows_count = self.active_rows.len();
94        if active_rows_count != row_len {
95            counts.push_str(", ");
96            let _ = write!(counts, "{active_rows_count} {}", t!("passing-filter"));
97        }
98
99        let selected_rows_count = self.selected_rows.len();
100        if selected_rows_count != 0 {
101            counts.push_str(", ");
102            let _ = write!(counts, "{selected_rows_count} {}", t!("selected"));
103        }
104
105        counts
106    }
107
108    /// Applies header actions (sorting updates and filtering changes) to the active indices.
109    pub fn process_responses(
110        &mut self,
111        provider: &dyn TableProvider,
112        responses: Vec<ColResponse>,
113    ) -> Result<(), TableError> {
114        let changes = self.collect_responses(responses);
115        let filter_update = changes.filter_update;
116        let sort_update = changes.sort_update;
117        let filter_state = changes.filter_state;
118        let sort_state = changes.sort_state;
119
120        if self.highlights_changed {
121            self.highlights_changed = false;
122            self.filter_cache_dirty = true;
123            self.sorted_children_cache.clear();
124
125            // Only apply flat filters if this is a flat table
126            if !provider.is_tree() {
127                self.apply_all_filters(provider, &filter_state)?;
128            }
129
130            if let Some((sort_col, sort_up)) = sort_state {
131                provider.sort_active_rows(&mut self.active_rows, sort_col, sort_up)?;
132            }
133            return Ok(());
134        }
135
136        if let Some(update) = filter_update {
137            self.sorted_children_cache.clear();
138
139            // Only apply flat filters if this is a flat table
140            if !provider.is_tree() {
141                match update {
142                    FilterUpdate::Added(col, filter) => {
143                        self.apply_incremental_filter(provider, col, &filter)?;
144                    }
145                    FilterUpdate::Removed(_) | FilterUpdate::Modified(_, _) => {
146                        self.apply_all_filters(provider, &filter_state)?;
147                    }
148                }
149            }
150
151            if let Some((sort_col, sort_up)) = sort_state {
152                provider.sort_active_rows(&mut self.active_rows, sort_col, sort_up)?;
153            }
154        } else if let Some(sort_col) = sort_update {
155            self.sorted_children_cache.clear();
156            if let Some((already_sorted_col, sort_up)) = sort_state {
157                if already_sorted_col == sort_col {
158                    let column = self
159                        .columns
160                        .get_mut(sort_col)
161                        .ok_or(TableError::CorruptedState)?;
162
163                    let new_sort_up = !sort_up;
164                    column.sort_up = Some(new_sort_up);
165
166                    // Apply the sorted indices to active_rows in the new direction
167                    provider.sort_active_rows(&mut self.active_rows, sort_col, new_sort_up)?;
168                } else {
169                    self.apply_new_sort(provider, sort_col)?;
170                }
171            } else {
172                self.apply_new_sort(provider, sort_col)?;
173            }
174        }
175
176        Ok(())
177    }
178
179    /// Sets up a new sorting column constraint and sorts active elements.
180    pub fn apply_new_sort(
181        &mut self,
182        provider: &dyn TableProvider,
183        sort_col: usize,
184    ) -> Result<(), TableError> {
185        for (i, column) in self.columns.iter_mut().enumerate() {
186            column.sort_up = if i == sort_col { Some(true) } else { None };
187        }
188        provider.sort_active_rows(&mut self.active_rows, sort_col, true)
189    }
190
191    /// Evaluates filtering constraints, resetting the active index map.
192    pub fn apply_all_filters(
193        &mut self,
194        provider: &dyn TableProvider,
195        filters: &[(usize, Filter)],
196    ) -> Result<(), TableError> {
197        self.active_rows = provider.filter_rows(self, filters)?;
198        Ok(())
199    }
200
201    /// Brings the active row set up to date only when the view is dirty, returning
202    /// `true` if it was recomputed this call. This is the intended per-frame entry
203    /// point: it is a cheap no-op when nothing has changed, avoiding a full O(N)
204    /// filter pass and sort every frame.
205    ///
206    /// For tree providers this delegates to [`Self::flatten_tree`]; for flat tables
207    /// it reapplies the active filters and (if any) the current sort.
208    pub fn refresh_view(&mut self, provider: &dyn TableProvider) -> Result<bool, TableError> {
209        if !self.filter_cache_dirty {
210            return Ok(false);
211        }
212
213        if provider.is_tree() {
214            self.flatten_tree(provider);
215        } else {
216            self.filter_cache_dirty = false;
217            let filter_state = self.get_filter_state();
218            self.apply_all_filters(provider, &filter_state)?;
219            if let Some((sort_col, sort_up)) = self.get_sort_state() {
220                provider.sort_active_rows(&mut self.active_rows, sort_col, sort_up)?;
221            }
222        }
223
224        Ok(true)
225    }
226
227    /// Runs filter constraints incrementally across the already-filtered row set.
228    pub fn apply_incremental_filter(
229        &mut self,
230        provider: &dyn TableProvider,
231        filter_col: usize,
232        filter: &Filter,
233    ) -> Result<(), TableError> {
234        let mut new_active = Vec::with_capacity(self.active_rows.len());
235
236        // Heuristic: If active rows are fewer than a threshold, look them up directly.
237        if self.active_rows.len() < 1000 {
238            for &row_idx in &self.active_rows {
239                if let Some(row) = provider.row_at(row_idx)? {
240                    let highlight = self.highlights.get_usize(row_idx);
241                    if let Some(cell) = row.cell(filter_col)
242                        && filter.matches(&cell.0, highlight)
243                    {
244                        new_active.push(row_idx);
245                    }
246                }
247            }
248        } else {
249            // Fallback to sequential scan if the active set is large
250            let active_set: RoaringBitmap = self.active_rows.iter().map(|&i| i as u32).collect();
251            let mut row_idx = 0;
252            provider.for_all_rows(&mut |row| {
253                if active_set.contains(row_idx as u32) {
254                    let highlight = self.highlights.get_usize(row_idx);
255                    if let Some(cell) = row.cell(filter_col)
256                        && filter.matches(&cell.0, highlight)
257                    {
258                        new_active.push(row_idx);
259                    }
260                }
261                row_idx += 1;
262                Ok(())
263            })?;
264        }
265
266        self.active_rows = new_active;
267        Ok(())
268    }
269
270    /// Renders the tree indentation guidelines and expand/collapse arrow inside a tree cell.
271    /// Returns `true` if the expansion state changed (allowing immediate-mode viewport updates).
272    pub fn show_tree_cell(
273        &mut self,
274        ui: &mut egui::Ui,
275        row_index: usize,
276        hierarchy: RowHierarchy,
277    ) -> bool {
278        let mut changed = false;
279
280        #[allow(clippy::cast_precision_loss)]
281        let spacing = hierarchy.indent_level as f32 * 22.0; // Comfortably spaced 22px indent
282        if spacing > 0.0 {
283            ui.add_space(spacing);
284
285            let rect = ui.max_rect();
286            let painter = ui.painter();
287            let stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(65, 65, 65));
288
289            let dash_length = 2.0;
290            let gap_length = 2.0;
291            let step = dash_length + gap_length;
292            let total_height = rect.max.y - rect.min.y;
293
294            if total_height > 0.0 {
295                let num_steps = (total_height / step).ceil() as usize;
296
297                let segments = (0..hierarchy.indent_level).flat_map(|i| {
298                    #[allow(clippy::cast_precision_loss)]
299                    let x = (i as f32).mul_add(22.0, rect.min.x) + 6.8;
300                    (0..num_steps).map(move |step_idx| {
301                        #[allow(clippy::cast_precision_loss)]
302                        let segment_y = (step_idx as f32).mul_add(step, rect.min.y);
303                        let next_y = (segment_y + dash_length).min(rect.max.y);
304                        egui::Shape::line_segment(
305                            [egui::pos2(x, segment_y), egui::pos2(x, next_y)],
306                            stroke,
307                        )
308                    })
309                });
310
311                painter.extend(segments);
312            }
313        }
314
315        ui.scope(|ui| {
316            // Bypass minimum interactive limits to let the region shrink to its natural 14px size
317            ui.spacing_mut().interact_size.x = 0.0;
318            ui.spacing_mut().button_padding = egui::vec2(2.0, 2.0);
319            ui.spacing_mut().item_spacing.x = 4.0;
320
321            if hierarchy.has_children {
322                let arrow = if hierarchy.is_expanded { "⏷" } else { "⏵" };
323
324                // Allocate an exact interactive rectangle
325                let (rect, response) =
326                    ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click());
327
328                // Resolve responsive colors based on interaction states
329                let arrow_color = if response.hovered() {
330                    if hierarchy.is_expanded {
331                        ui.visuals().warn_fg_color.linear_multiply(0.9)
332                    } else {
333                        ui.visuals().widgets.hovered.text_color()
334                    }
335                } else if hierarchy.is_expanded {
336                    ui.visuals().widgets.active.text_color()
337                } else {
338                    ui.visuals()
339                        .widgets
340                        .inactive
341                        .text_color()
342                        .linear_multiply(0.5)
343                };
344
345                // Draw the glyph centered inside the allocated rectangle
346                ui.painter().text(
347                    rect.center(),
348                    egui::Align2::CENTER_CENTER,
349                    arrow,
350                    egui::FontId::proportional(11.0),
351                    arrow_color,
352                );
353
354                if response.clicked() {
355                    if hierarchy.is_expanded {
356                        self.expanded_rows.remove(row_index as u32);
357                    } else {
358                        self.expanded_rows.insert(row_index as u32);
359                    }
360
361                    self.sorted_children_cache.remove(&row_index);
362                    changed = true;
363                }
364            } else {
365                let dummy_arrow = egui::RichText::new("⏵").color(egui::Color32::TRANSPARENT);
366                ui.add_enabled_ui(false, |ui| {
367                    let _ = ui.selectable_label(false, dummy_arrow);
368                });
369            }
370        });
371
372        changed
373    }
374
375    /// Evaluates clicks and modifier keys to update row selections.
376    pub fn handle_row_selection(&mut self, modifiers: egui::Modifiers, row_index: usize) {
377        let selected_rows = &mut self.selected_rows;
378        let active_rows = &self.active_rows;
379        let row_idx_u32 = row_index as u32;
380
381        if modifiers.command || modifiers.ctrl {
382            if selected_rows.contains(row_idx_u32) {
383                selected_rows.remove(row_idx_u32);
384                self.last_clicked_visible_index = None;
385            } else {
386                selected_rows.insert(row_idx_u32);
387                self.last_clicked_visible_index = active_rows.iter().position(|&r| r == row_index);
388            }
389        } else if modifiers.shift && self.last_clicked_visible_index.is_some() {
390            if let Some(anchor_visible_pos) = self.last_clicked_visible_index
391                && let Some(current_visible_pos) = active_rows.iter().position(|&r| r == row_index)
392            {
393                let start = anchor_visible_pos.min(current_visible_pos);
394                let end = anchor_visible_pos.max(current_visible_pos);
395                for visible_idx in start..=end {
396                    if let Some(&actual_row_idx) = active_rows.get(visible_idx) {
397                        selected_rows.insert(actual_row_idx as u32);
398                    }
399                }
400            }
401        } else if selected_rows.len() == 1 && selected_rows.contains(row_idx_u32) {
402            selected_rows.clear();
403            self.last_clicked_visible_index = None;
404        } else {
405            selected_rows.clear();
406            selected_rows.insert(row_idx_u32);
407            self.last_clicked_visible_index = active_rows.iter().position(|&r| r == row_index);
408        }
409    }
410
411    /// Rebuilds the O(N) reverse-propagation subtree filter cache from scratch if dirty.
412    pub fn rebuild_tree_filter_cache(&mut self, provider: &dyn TableProvider) {
413        let row_count = provider.row_count();
414
415        // Invalidate and clear sibling sort caches if the snapshot shifted (row count changed)
416        let is_empty = self.filter_matches.is_empty();
417        if self.filter_cache_dirty || is_empty {
418            self.sorted_children_cache.clear();
419        }
420
421        if !self.filter_cache_dirty && !is_empty {
422            return; // Cache is warm: do nothing!
423        }
424        self.filter_cache_dirty = false;
425        self.filter_matches.clear();
426
427        let active_filters = self.get_filter_state();
428        if active_filters.is_empty() {
429            // Memory Optimization: populate the entire range in O(1) time
430            self.filter_matches.insert_range(0..row_count as u32);
431            return;
432        }
433
434        // Single-pass O(N) reverse propagation of matching subtrees
435        for row_idx in (0..row_count).rev() {
436            let highlight = self.highlights.get_usize(row_idx);
437            let matches = provider.row_matches(self, row_idx, &active_filters, highlight);
438
439            if matches {
440                self.filter_matches.insert(row_idx as u32);
441            }
442
443            // Propagate match state upwards to parents
444            if self.filter_matches.contains(row_idx as u32)
445                && let Some(parent_idx) = provider.row_parent(row_idx)
446            {
447                self.filter_matches.insert(parent_idx as u32);
448            }
449        }
450    }
451
452    /// Recursively flattens the visible tree nodes matching the active filters into `active_rows`.
453    pub fn flatten_tree(&mut self, provider: &dyn TableProvider) {
454        self.rebuild_tree_filter_cache(provider);
455
456        let mut active = Vec::with_capacity(provider.row_count());
457        if provider.row_count() > 0 {
458            // Flatten from the root node (0) downwards
459            self.flatten_tree_impl(provider, 0, &mut active);
460        }
461        self.active_rows = active;
462    }
463
464    fn flatten_tree_impl(
465        &mut self,
466        provider: &dyn TableProvider,
467        row_idx: usize,
468        out: &mut Vec<usize>,
469    ) {
470        // Fast, O(log C) random access check over compressed roaring bitmap containers
471        if !self.filter_matches.contains(row_idx as u32) {
472            return; // Subtree does not match filters: discard early
473        }
474
475        out.push(row_idx);
476
477        let is_expanded = self.expanded_rows.contains(row_idx as u32);
478        if is_expanded {
479            // `Arc::clone` is a cheap refcount bump and lets us hand the children to
480            // the recursive `&mut self` call without cloning the underlying `Vec`.
481            let sorted_children = if let Some(cached) = self.sorted_children_cache.get(&row_idx) {
482                Arc::clone(cached)
483            } else {
484                let mut children = provider.row_children(row_idx);
485                let sort_state = self.get_sort_state();
486                if let Some((sort_col, sort_up)) = sort_state {
487                    let _ = provider.sort_active_rows(&mut children, sort_col, sort_up);
488                }
489                let children = Arc::new(children);
490                self.sorted_children_cache
491                    .insert(row_idx, Arc::clone(&children));
492                children
493            };
494
495            for &child_idx in sorted_children.iter() {
496                self.flatten_tree_impl(provider, child_idx, out);
497            }
498        }
499    }
500}
501
502pub trait TableStateExt {
503    fn collect_responses(&mut self, responses: Vec<ColResponse>) -> TableChanges;
504}
505
506impl TableStateExt for TableState {
507    fn collect_responses(&mut self, responses: Vec<ColResponse>) -> TableChanges {
508        let mut filter_update = None;
509        let mut sort_update = None;
510        let mut filter_state = Vec::with_capacity(responses.len());
511
512        // Ensure the vector capacity covers the received header layout size
513        if self.columns.len() < responses.len() {
514            self.columns
515                .resize_with(responses.len(), ColumnState::default);
516        }
517
518        for (col_index, response) in responses.into_iter().enumerate() {
519            let col = &mut self.columns[col_index];
520            let old_active = !col.response.filtering.is_empty();
521            let new_active = !response.filtering.is_empty();
522
523            if !old_active && new_active {
524                filter_update = Some(FilterUpdate::Added(col_index, response.filtering.clone()));
525                self.filter_cache_dirty = true;
526            } else if old_active && !new_active {
527                filter_update = Some(FilterUpdate::Removed(col_index));
528                self.filter_cache_dirty = true;
529            } else if old_active && new_active {
530                let old = &col.response.filtering;
531                let new = &response.filtering;
532
533                if old.search.text() != new.search.text()
534                    || old.search.options() != new.search.options()
535                    || old.highlight != new.highlight
536                {
537                    filter_update = Some(FilterUpdate::Modified(
538                        col_index,
539                        response.filtering.clone(),
540                    ));
541                    self.filter_cache_dirty = true;
542                }
543            }
544
545            if new_active {
546                filter_state.push((col_index, response.filtering.clone()));
547            }
548
549            col.response = response;
550            if col.response.to_sort {
551                col.response.to_sort = false;
552                sort_update = Some(col_index);
553            }
554        }
555
556        let sort_state = self.get_sort_state();
557
558        TableChanges {
559            filter_update,
560            sort_update,
561            filter_state,
562            sort_state,
563        }
564    }
565}