Skip to main content

egui_table_kit/layout/
table.rs

1use std::{
2    collections::{BTreeMap, btree_map::Entry},
3    ops::{Range, RangeInclusive},
4};
5
6use egui::{
7    Align, Context, Id, IdMap, IdSalt, Layout, NumExt as _, Rangef, Rect, Response, Ui, UiBuilder,
8    Vec2, Vec2b, vec2,
9};
10
11use super::{
12    SplitScroll, SplitScrollDelegate,
13    columns::{Column, ColumnFlags},
14};
15
16// TODO: fix the functionality of this
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
18pub enum AutoSizeMode {
19    /// Never auto-size the columns.
20    #[default]
21    Never,
22
23    /// Always auto-size the columns
24    Always,
25
26    /// Auto-size the columns if the parents' width changes
27    OnParentResize,
28}
29
30#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
31pub struct TableState {
32    // Maps columns ids to their widths.
33    pub col_widths: IdMap<f32>,
34
35    pub parent_width: Option<f32>,
36}
37
38impl TableState {
39    #[must_use]
40    pub fn load(ctx: &egui::Context, id: Id) -> Option<Self> {
41        ctx.data_mut(|d| d.get_persisted(id))
42    }
43
44    pub fn store(self, ctx: &egui::Context, id: Id) {
45        ctx.data_mut(|d| d.insert_persisted(id, self));
46    }
47
48    #[must_use]
49    pub fn id(ui: &Ui, id_salt: IdSalt) -> Id {
50        ui.make_persistent_id(id_salt)
51    }
52
53    pub fn reset(ctx: &egui::Context, id: Id) {
54        ctx.data_mut(|d| {
55            d.remove::<Self>(id);
56        });
57    }
58}
59
60/// Describes one of potentially many header rows.
61///
62/// Each header row has a fixed height.
63#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
64pub struct HeaderRow {
65    pub height: f32,
66
67    /// If empty, it is ignored.
68    ///
69    /// Contains non-overlapping ranges of column indices to group together.
70    /// For instance: `vec![(0..3), (3..5), (5..6)]`.
71    pub groups: Vec<Range<usize>>,
72}
73
74impl HeaderRow {
75    #[must_use]
76    pub const fn new(height: f32) -> Self {
77        Self {
78            height,
79            groups: Vec::new(),
80        }
81    }
82}
83
84/// A table viewer.
85///
86/// Designed to be fast when there are millions of rows, but only hundreds of columns.
87///
88/// ## Sticky columns and rows
89/// You can designate a certain number of column and rows as being "sticky".
90/// These won't scroll with the rest of the table.
91///
92/// The sticky rows are always the first ones at the top, and are usually used for the column headers.
93/// The sticky columns are always the first ones on the left, useful for special columns like
94/// table row number or similar.
95/// A sticky column is sometimes called a "gutter".
96///
97/// ## Batteries not included
98/// * You need to specify the `Table` size beforehand
99/// * Does not add any margins to cells. Add it yourself with [`egui::Frame`].
100/// * Does not wrap cells in scroll areas. Do that yourself.
101/// * Doesn't paint any guide-lines for the rows. Paint them yourself.
102pub struct Table {
103    /// The columns of the table.
104    columns: Vec<Column>,
105
106    /// Salt added to the parent [`Ui::id`] to produce an [`Id`] that is unique
107    /// within the parent [`Ui`].
108    ///
109    /// You need to set this to something unique if you have multiple tables in the same ui.
110    id_salt: IdSalt,
111
112    /// Which columns are sticky (non-scrolling)?
113    num_sticky_cols: usize,
114
115    /// The count and parameters of the sticky (non-scrolling) header rows.
116    headers: Vec<HeaderRow>,
117
118    /// Total number of rows (sticky + non-sticky).
119    num_rows: u64,
120
121    /// How to do auto-sizing of columns, if at all.
122    auto_size_mode: AutoSizeMode,
123
124    scroll_to_columns: Option<(RangeInclusive<usize>, Option<Align>)>,
125    scroll_to_rows: Option<(RangeInclusive<u64>, Option<Align>)>,
126
127    /// If true, the vertical scrollbar will stick to the bottom as the content grows.
128    ///
129    /// Useful for log views or terminal emulation.
130    stick_to_bottom: bool,
131    max_height: Option<f32>,
132    max_rows: Option<u64>,
133}
134
135impl Default for Table {
136    fn default() -> Self {
137        Self {
138            columns: vec![],
139            id_salt: IdSalt::new("table"),
140            num_sticky_cols: 0,
141            headers: vec![HeaderRow::new(16.0)],
142            num_rows: 0,
143            auto_size_mode: AutoSizeMode::default(),
144            scroll_to_columns: None,
145            scroll_to_rows: None,
146            stick_to_bottom: false,
147            max_height: None,
148            max_rows: None,
149        }
150    }
151}
152
153#[derive(Clone, Debug)]
154#[non_exhaustive]
155pub struct CellInfo {
156    pub col_nr: usize,
157
158    pub row_nr: u64,
159
160    /// The unique [`Id`] of this table.
161    pub table_id: Id,
162
163    /// Is the row hovered?
164    pub row_hovered: bool,
165}
166
167#[derive(Clone, Debug)]
168#[non_exhaustive]
169pub struct HeaderCellInfo {
170    pub group_index: usize,
171
172    pub col_range: Range<usize>,
173
174    /// Header row
175    pub row_nr: usize,
176
177    /// The unique [`Id`] of this table.
178    pub table_id: Id,
179}
180
181/// Data given to the delegate containing information about what is about to be rendered.
182#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
183#[non_exhaustive]
184pub struct PrefetchInfo {
185    /// The sticky columns are always visible.
186    pub num_sticky_columns: usize,
187
188    /// This range of columns are currently visible, in addition to the sticky ones.
189    pub visible_columns: Range<usize>,
190
191    /// These rows are currently visible.
192    pub visible_rows: Range<u64>,
193
194    /// The unique [`Id`] of this table.
195    pub table_id: Id,
196}
197
198/// The interface that the user needs to implement to display a table.
199///
200/// The [`Table`] calls functions on the delegate to render the table.
201pub trait TableDelegate {
202    /// Called before any call to [`Self::cell_ui`] to communicate the range of visible columns and rows.
203    ///
204    /// You can use this to only load the data required to be viewed.
205    fn prepare(&mut self, _info: &PrefetchInfo) {}
206
207    /// The contents of a header cell in the table.
208    ///
209    /// The [`CellInfo::row_nr`] is which header row (usually 0).
210    fn header_cell_ui(&mut self, ui: &mut Ui, cell: &HeaderCellInfo);
211
212    /// The contents of a row.
213    ///
214    /// Individual cell [`Ui`]s will be children of the ui passed to this fn, so you can e.g. use
215    /// [`Ui::style_mut`] to style the whole row.
216    ///
217    /// This might be called multiple times per row (e.g. for sticky and non-sticky columns).
218    fn row_ui(&mut self, _ui: &mut Ui, _row_nr: u64) {}
219
220    /// The contents of a cell in the table.
221    ///
222    /// The [`CellInfo::row_nr`] is ignoring header rows.
223    fn cell_ui(&mut self, ui: &mut Ui, cell: &CellInfo);
224
225    /// Compute the offset for the top of the given row.
226    ///
227    /// Implement this for arbitrary row heights. The default implementation uses
228    /// [`Self::default_row_height`].
229    ///
230    /// Note: must always return 0.0 for `row_nr = 0`.
231    #[allow(clippy::cast_precision_loss)]
232    fn row_top_offset(&self, _ctx: &Context, _table_id: Id, row_nr: u64) -> f32 {
233        row_nr as f32 * self.default_row_height()
234    }
235
236    /// Default row height.
237    ///
238    /// This is used by the default implementation of [`Self::row_top_offset`].
239    fn default_row_height(&self) -> f32 {
240        20.0
241    }
242
243    fn uniform_row_height(&self) -> Option<f32> {
244        Some(self.default_row_height())
245    }
246}
247
248impl Table {
249    /// Create a new table, with no columns and no headers, and zero rows.
250    #[must_use]
251    #[inline]
252    pub fn new() -> Self {
253        Self::default()
254    }
255
256    /// Salt added to the parent [`Ui::id`] to produce an [`Id`] that is unique
257    /// within the parent [`Ui`].
258    ///
259    /// You need to set this to something unique if you have multiple tables in the same ui.
260    #[must_use]
261    #[inline]
262    pub fn id_salt(mut self, id_salt: impl egui::AsIdSalt) -> Self {
263        self.id_salt = IdSalt::new(id_salt);
264        self
265    }
266
267    #[must_use]
268    #[inline]
269    pub const fn max_rows(mut self, max_rows: u64) -> Self {
270        self.max_rows = Some(max_rows);
271        self
272    }
273
274    /// Total number of rows (sticky + non-sticky).
275    #[must_use]
276    #[inline]
277    pub const fn num_rows(mut self, num_rows: u64) -> Self {
278        self.num_rows = num_rows;
279        self
280    }
281
282    /// The columns of the table.
283    #[must_use]
284    #[inline]
285    pub fn columns(mut self, columns: impl Into<Vec<Column>>) -> Self {
286        self.columns = columns.into();
287        self
288    }
289
290    /// How many columns are sticky (non-scrolling)?
291    ///
292    /// Default is 0.
293    #[must_use]
294    #[inline]
295    pub const fn num_sticky_cols(mut self, num_sticky_cols: usize) -> Self {
296        self.num_sticky_cols = num_sticky_cols;
297        self
298    }
299
300    /// The count and parameters of the sticky (non-scrolling) header rows.
301    #[must_use]
302    #[inline]
303    pub fn headers(mut self, headers: impl Into<Vec<HeaderRow>>) -> Self {
304        self.headers = headers.into();
305        self
306    }
307
308    /// How to do auto-sizing of columns, if at all.
309    #[must_use]
310    #[inline]
311    pub const fn auto_size_mode(mut self, auto_size_mode: AutoSizeMode) -> Self {
312        self.auto_size_mode = auto_size_mode;
313        self
314    }
315
316    #[must_use]
317    #[inline]
318    pub const fn max_height(mut self, max_height: f32) -> Self {
319        self.max_height = Some(max_height);
320        self
321    }
322
323    /// The scroll handle will stick to the bottom position even while the content size
324    /// changes dynamically.
325    ///
326    /// This can be useful to simulate terminal UIs or log/info scrollers.
327    /// The scroll handle remains stuck until user manually changes position. Once "unstuck"
328    /// it will remain focused on whatever content viewport the user left it on.
329    #[must_use]
330    #[inline]
331    pub const fn stick_to_bottom(mut self, stick: bool) -> Self {
332        self.stick_to_bottom = stick;
333        self
334    }
335
336    /// Read the globally unique id, based on the current [`Self::id_salt`]
337    /// and the parent id.
338    #[must_use]
339    #[inline]
340    pub fn get_id(&self, ui: &Ui) -> Id {
341        TableState::id(ui, self.id_salt)
342    }
343
344    /// Set a row to scroll to.
345    ///
346    /// `align` specifies if the row should be positioned in the top, center, or bottom of the view
347    /// (using [`Align::TOP`], [`Align::Center`] or [`Align::BOTTOM`]).
348    /// If `align` is `None`, the table will scroll just enough to bring the cursor into view.
349    ///
350    /// See also: [`Self::scroll_to_column`].
351    #[must_use]
352    #[inline]
353    pub const fn scroll_to_row(self, row: u64, align: Option<Align>) -> Self {
354        self.scroll_to_rows(row..=row, align)
355    }
356
357    /// Scroll to a range of rows.
358    ///
359    /// See [`Self::scroll_to_row`] for details.
360    #[must_use]
361    #[inline]
362    pub const fn scroll_to_rows(mut self, rows: RangeInclusive<u64>, align: Option<Align>) -> Self {
363        self.scroll_to_rows = Some((rows, align));
364        self
365    }
366
367    /// Set a column to scroll to.
368    ///
369    /// `align` specifies if the column should be positioned in the left, center, or right of the view
370    /// (using [`Align::LEFT`], [`Align::Center`] or [`Align::RIGHT`]).
371    /// If `align` is `None`, the table will scroll just enough to bring the cursor into view.
372    ///
373    /// See also: [`Self::scroll_to_row`].
374    #[must_use]
375    #[inline]
376    pub const fn scroll_to_column(self, column: usize, align: Option<Align>) -> Self {
377        self.scroll_to_columns(column..=column, align)
378    }
379
380    /// Scroll to a range of columns.
381    ///
382    /// See [`Self::scroll_to_column`] for details.
383    #[must_use]
384    #[inline]
385    pub const fn scroll_to_columns(
386        mut self,
387        columns: RangeInclusive<usize>,
388        align: Option<Align>,
389    ) -> Self {
390        self.scroll_to_columns = Some((columns, align));
391        self
392    }
393
394    /// The top y coordinate offset of a specific row nr.
395    ///
396    /// `get_row_top_offset(0)` should always return 0.0.
397    #[expect(clippy::unused_self)] // for uniformity
398    fn get_row_top_offset(
399        &self,
400        ctx: &Context,
401        table_id: Id,
402        table_delegate: &dyn TableDelegate,
403        row_nr: u64,
404    ) -> f32 {
405        table_delegate.row_top_offset(ctx, table_id, row_nr)
406    }
407
408    /// Which row contains the given y offset (from the top)?
409    fn get_row_nr_at_y_offset(
410        &self,
411        ctx: &Context,
412        table_id: Id,
413        table_delegate: &dyn TableDelegate,
414        y_offset: f32,
415    ) -> u64 {
416        if let Some(height) = table_delegate.uniform_row_height()
417            && height > 0.0
418        {
419            return ((y_offset / height) as u64).at_most(self.num_rows.saturating_sub(1));
420        }
421
422        // Fall back to binary search for variable heights
423        partition_point(0..=self.num_rows, |row_nr| {
424            y_offset <= self.get_row_top_offset(ctx, table_id, table_delegate, row_nr)
425        })
426        .saturating_sub(1)
427    }
428
429    pub fn show(mut self, ui: &mut Ui, table_delegate: &mut dyn TableDelegate) -> Response {
430        self.num_sticky_cols = self.num_sticky_cols.at_most(self.columns.len());
431
432        let id = TableState::id(ui, self.id_salt);
433        let state = TableState::load(ui, id);
434        let is_new = state.is_none();
435        let mut state = state.unwrap_or_default();
436
437        for (i, column) in self.columns.iter_mut().enumerate() {
438            let column_id = column.id_for(i);
439            let cached_width = state.col_widths.get(&column_id).copied();
440            if let Some(existing_width) = cached_width {
441                column.current = existing_width;
442            } else {
443                // If it is a new column and configured for auto-fitting, trigger sizing pass
444                if column.is_auto_fit() {
445                    column.flags.set(ColumnFlags::AUTO_SIZE_THIS_FRAME, true);
446                }
447            }
448            column.current = column.range.clamp(column.current);
449
450            // Only run the initial sizing pass on columns configured for auto-fitting
451            if is_new && column.is_auto_fit() {
452                column.flags.set(ColumnFlags::AUTO_SIZE_THIS_FRAME, true);
453            }
454        }
455
456        // Only do full sizing pass if there are any columns that actually need to be auto-fitted
457        let do_full_sizing_pass = is_new
458            && self
459                .columns
460                .iter()
461                .any(super::columns::Column::is_auto_size_this_frame);
462
463        let parent_width = ui.available_width();
464        let auto_size = match self.auto_size_mode {
465            AutoSizeMode::Never => false,
466            AutoSizeMode::Always => true,
467            AutoSizeMode::OnParentResize => state.parent_width != Some(parent_width),
468        };
469        if auto_size {
470            Column::auto_size(&mut self.columns, parent_width);
471        }
472        state.parent_width = Some(parent_width);
473
474        let col_x = {
475            let mut x = ui.cursor().min.x;
476            let mut col_x = Vec::with_capacity(self.columns.len() + 1);
477            col_x.push(x);
478            for column in &self.columns {
479                x += column.current;
480                col_x.push(x);
481            }
482            col_x
483        };
484
485        let header_row_y = {
486            let mut y = ui.cursor().min.y;
487            let mut sticky_row_y = Vec::with_capacity(self.headers.len() + 1);
488            sticky_row_y.push(y);
489            for header in &self.headers {
490                y += header.height;
491                sticky_row_y.push(y);
492            }
493            sticky_row_y
494        };
495
496        let sticky_size = Vec2::new(
497            self.columns[..self.num_sticky_cols]
498                .iter()
499                .map(|c| c.current)
500                .sum(),
501            self.headers.iter().map(|h| h.height).sum(),
502        );
503
504        let mut ui_builder = UiBuilder::new().layout(Layout::top_down(Align::Min));
505        if do_full_sizing_pass {
506            ui_builder = ui_builder.sizing_pass().invisible();
507            ui.request_discard("Full egui_table sizing");
508        }
509        let response = ui
510            .scope_builder(ui_builder, |ui| {
511                // Don't wrap text in the table cells.
512                ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
513
514                let num_columns = self.columns.len();
515
516                for (col_nr, column) in self.columns.iter_mut().enumerate() {
517                    if column.is_resizable() {
518                        let column_resize_id = id.with(column.id_for(col_nr)).with("resize");
519                        if let Some(response) = ui.read_response(column_resize_id)
520                            && response.double_clicked()
521                        {
522                            column.flags.set(ColumnFlags::AUTO_SIZE_THIS_FRAME, true);
523                        }
524                    }
525                    if column.is_auto_size_this_frame() {
526                        ui.request_discard("egui_table column sizing");
527                    }
528                }
529
530                SplitScroll {
531                    scroll_enabled: Vec2b::new(true, true),
532                    fixed_size: sticky_size,
533                    scroll_outer_size: {
534                        // Calculate the combined height of the headers and rows
535                        let total_rows_height =
536                            self.get_row_top_offset(ui, id, table_delegate, self.num_rows);
537                        let total_content_height = sticky_size.y + total_rows_height;
538
539                        // Ensure a minimum height of up to 10 rows (or self.num_rows if smaller)
540                        // to prevent collapsing to header-only height during animations/sizing passes.
541                        let min_rows = self.num_rows.min(10);
542                        let min_rows_height =
543                            self.get_row_top_offset(ui, id, table_delegate, min_rows);
544                        let min_table_height = sticky_size.y + min_rows_height;
545
546                        // Calculate the maximum allowed height based on row limits, max pixel limits, or the visible clip rect.
547                        let max_height_limit = if let Some(max_r) = self.max_rows {
548                            let max_rows_height =
549                                self.get_row_top_offset(ui, id, table_delegate, max_r);
550                            sticky_size.y + max_rows_height
551                        } else {
552                            self.max_height.unwrap_or_else(|| ui.clip_rect().height())
553                        };
554
555                        let available_height = ui
556                            .available_height()
557                            .at_most(max_height_limit)
558                            .max(min_table_height);
559
560                        let allocated_height = total_content_height.min(available_height);
561
562                        Vec2::new(
563                            (ui.available_width() - sticky_size.x).max(0.0),
564                            (allocated_height - sticky_size.y).max(0.0),
565                        )
566                    },
567                    scroll_content_size: Vec2::new(
568                        self.columns[self.num_sticky_cols..]
569                            .iter()
570                            .map(|c| c.current)
571                            .sum(),
572                        self.get_row_top_offset(ui, id, table_delegate, self.num_rows),
573                    ),
574                    stick_to_bottom: self.stick_to_bottom,
575                }
576                .show(
577                    ui,
578                    &mut TableSplitScrollDelegate {
579                        id,
580                        table_delegate,
581                        state: &mut state,
582                        table: &mut self,
583                        col_x,
584                        header_row_y,
585                        max_column_widths: vec![0.0; num_columns],
586                        visible_column_lines: BTreeMap::default(),
587                        do_full_sizing_pass,
588                        has_prefetched: false,
589                        egui_ctx: ui.clone(),
590                        col_interaction: BTreeMap::default(),
591                        dragging_col: None,
592                    },
593                );
594            })
595            .response;
596
597        state.store(ui, id);
598        response
599    }
600}
601
602#[derive(Clone, Copy, Debug)]
603struct ColumnResizer {
604    scroll_offset: Vec2,
605
606    top: f32,
607}
608
609fn update(map: &mut BTreeMap<usize, ColumnResizer>, key: usize, value: ColumnResizer) {
610    match map.entry(key) {
611        Entry::Vacant(entry) => {
612            entry.insert(value);
613        }
614        Entry::Occupied(mut entry) => {
615            entry.get_mut().top = entry.get_mut().top.min(value.top);
616        }
617    }
618}
619
620struct TableSplitScrollDelegate<'a> {
621    id: Id,
622    table_delegate: &'a mut dyn TableDelegate,
623    table: &'a mut Table,
624    state: &'a mut TableState,
625
626    /// The x coordinate for the start of each column, plus the end of the last column.
627    col_x: Vec<f32>,
628
629    /// The y coordinate for the start of each header row, plus the end of the last header row.
630    header_row_y: Vec<f32>,
631
632    /// Actual width of the widest element in each column
633    max_column_widths: Vec<f32>,
634
635    /// Key is column number. The resizer is to the right of the column.
636    visible_column_lines: BTreeMap<usize, ColumnResizer>,
637
638    do_full_sizing_pass: bool,
639
640    has_prefetched: bool,
641
642    egui_ctx: Context,
643
644    col_interaction: BTreeMap<usize, (bool, bool)>,
645    dragging_col: Option<usize>,
646}
647
648impl TableSplitScrollDelegate<'_> {
649    /// Helper wrapper around [`Table::get_row_top_offset`].
650    fn get_row_top_offset(&self, row_nr: u64) -> f32 {
651        self.table
652            .get_row_top_offset(&self.egui_ctx, self.id, self.table_delegate, row_nr)
653    }
654
655    /// Helper wrapper around [`Table::get_row_nr_at_y_offset`].
656    fn get_row_nr_at_y_offset(&self, y_offset: f32) -> u64 {
657        self.table
658            .get_row_nr_at_y_offset(&self.egui_ctx, self.id, self.table_delegate, y_offset)
659    }
660
661    fn header_ui(&mut self, ui: &mut Ui, scroll_offset: Vec2) {
662        // Compute the visible column range for the current quadrant viewport
663        let viewport = ui.clip_rect().translate(scroll_offset);
664
665        #[allow(clippy::float_cmp)]
666        let col_range = if self.table.columns.is_empty() || viewport.left() == viewport.right() {
667            0..0
668        } else if self.do_full_sizing_pass {
669            // Render all columns during a sizing pass to measure layout constraints
670            0..self.table.columns.len()
671        } else {
672            let col_idx_at = |x: f32| -> usize {
673                self.col_x
674                    .partition_point(|&col_x| col_x < x)
675                    .saturating_sub(1)
676                    .at_most(self.table.columns.len() - 1)
677            };
678
679            col_idx_at(viewport.min.x)..col_idx_at(viewport.max.x) + 1
680        };
681
682        let last_header_row_y = self.header_row_y.last().copied().unwrap_or(0.0);
683
684        for (row_nr, header_row) in self.table.headers.iter().enumerate() {
685            let groups = if header_row.groups.is_empty() {
686                (0..self.table.columns.len()).map(|i| i..i + 1).collect()
687            } else {
688                header_row.groups.clone()
689            };
690
691            let y_range = Rangef::new(self.header_row_y[row_nr], self.header_row_y[row_nr + 1]);
692
693            for (group_index, col_range_group) in groups.into_iter().enumerate() {
694                let start = col_range_group.start;
695                let end = col_range_group.end;
696
697                // Skip processing and rendering if this group is outside the quadrant's visible column span
698                if end <= col_range.start || start >= col_range.end {
699                    continue;
700                }
701
702                let mut header_rect =
703                    Rect::from_x_y_ranges(self.col_x[start]..=self.col_x[end], y_range)
704                        .translate(-scroll_offset);
705
706                if 0 < start
707                    && self.table.columns[start - 1].is_resizable()
708                    && ui.clip_rect().x_range().contains(header_rect.left())
709                {
710                    // The previous column is resizable, so make sure the resize line goes to above this heading:
711                    update(
712                        &mut self.visible_column_lines,
713                        start - 1,
714                        ColumnResizer {
715                            scroll_offset,
716                            top: header_rect.top(),
717                        },
718                    );
719                }
720
721                let clip_rect = header_rect;
722
723                let last_column = &self.table.columns[end - 1];
724                let auto_size_this_frame = last_column.is_auto_size_this_frame();
725
726                if auto_size_this_frame {
727                    header_rect.max.x = header_rect.min.x
728                        + self.table.columns[start..end]
729                            .iter()
730                            .map(|column| column.range.min)
731                            .sum::<f32>();
732                }
733
734                let mut ui_builder = UiBuilder::new()
735                    .max_rect(header_rect)
736                    .id_salt(("header", row_nr, group_index))
737                    .layout(egui::Layout::left_to_right(egui::Align::Center));
738                if auto_size_this_frame {
739                    ui_builder = ui_builder.sizing_pass();
740                }
741                let mut cell_ui = ui.new_child(ui_builder);
742                cell_ui.shrink_clip_rect(clip_rect);
743
744                self.table_delegate.header_cell_ui(
745                    &mut cell_ui,
746                    &HeaderCellInfo {
747                        group_index,
748                        col_range: col_range_group,
749                        row_nr,
750                        table_id: self.id,
751                    },
752                );
753
754                if start + 1 == end {
755                    // normal single-column group
756                    let col_nr = start;
757                    let column = &self.table.columns[start];
758                    let width = &mut self.max_column_widths[col_nr];
759                    *width = width.max(cell_ui.min_size().x);
760
761                    // Save column lines for later interaction:
762                    if column.is_resizable()
763                        && ui.clip_rect().x_range().contains(header_rect.right())
764                    {
765                        update(
766                            &mut self.visible_column_lines,
767                            col_nr,
768                            ColumnResizer {
769                                scroll_offset,
770                                top: header_rect.top(),
771                            },
772                        );
773                    }
774                }
775            }
776        }
777
778        // Repaint separator lines over the headers so they aren't covered by header backgrounds.
779        for (col_nr, ColumnResizer { scroll_offset, top }) in &self.visible_column_lines {
780            let col_nr = *col_nr;
781            let Some(column) = self.table.columns.get(col_nr) else {
782                continue;
783            };
784            if !column.is_resizable() {
785                continue;
786            }
787
788            let column_id = column.id_for(col_nr);
789            let new_width = self
790                .state
791                .col_widths
792                .get(&column_id)
793                .copied()
794                .unwrap_or(column.current);
795            let old_width = column.current;
796
797            let x = self.col_x[col_nr + 1] - scroll_offset.x + (new_width - old_width);
798            let yrange = Rangef::new(*top, last_header_row_y);
799
800            let (hovered, dragged) = self
801                .col_interaction
802                .get(&col_nr)
803                .copied()
804                .unwrap_or((false, false));
805            let stroke = if dragged {
806                ui.style().visuals.widgets.active.bg_stroke
807            } else if hovered {
808                ui.style().visuals.widgets.hovered.bg_stroke
809            } else {
810                ui.visuals().widgets.noninteractive.bg_stroke
811            };
812
813            ui.painter().vline(x, yrange, stroke);
814        }
815    }
816
817    fn region_ui(&mut self, ui: &mut Ui, scroll_offset: Vec2, do_prefetch: bool) {
818        // Used to find the visible range of columns and rows:
819        let viewport = ui.clip_rect().translate(scroll_offset);
820        let last_header_row_y = self.header_row_y.last().copied().unwrap_or(0.0);
821
822        #[allow(clippy::float_cmp)]
823        let col_range = if self.table.columns.is_empty() || viewport.left() == viewport.right() {
824            0..0
825        } else if self.do_full_sizing_pass {
826            // We do the UI for all columns during a sizing pass, so we can auto-size ALL columns
827            0..self.table.columns.len()
828        } else {
829            // Only paint the visible columns:
830            let col_idx_at = |x: f32| -> usize {
831                self.col_x
832                    .partition_point(|&col_x| col_x < x)
833                    .saturating_sub(1)
834                    .at_most(self.table.columns.len() - 1)
835            };
836
837            col_idx_at(viewport.min.x)..col_idx_at(viewport.max.x) + 1
838        };
839
840        #[allow(clippy::float_cmp)]
841        let row_range = if self.table.num_rows == 0 || viewport.top() == viewport.bottom() {
842            0..0
843        } else {
844            // Only paint the visible rows:
845            let row_idx_at = |y: f32| -> u64 {
846                let row_nr = self.get_row_nr_at_y_offset(y - last_header_row_y);
847                row_nr.at_most(self.table.num_rows.saturating_sub(1))
848            };
849
850            let margin = if do_prefetch {
851                1.0 // Handle possible rounding errors in the syncing of the scroll offsets
852            } else {
853                0.0
854            };
855
856            row_idx_at(viewport.min.y - margin)..row_idx_at(viewport.max.y + margin) + 1
857        };
858
859        if do_prefetch {
860            self.table_delegate.prepare(&PrefetchInfo {
861                num_sticky_columns: self.table.num_sticky_cols,
862                visible_columns: col_range.clone(),
863                visible_rows: row_range.clone(),
864                table_id: self.id,
865            });
866            self.has_prefetched = true;
867        } else {
868            debug_assert!(
869                self.has_prefetched,
870                "SplitScroll delegate methods called in unexpected order"
871            );
872        }
873
874        let pointer_pos = ui.ctx().pointer_latest_pos();
875        let current_frame = ui.ctx().cumulative_frame_nr();
876        let hovered_row_id = self.id.with("hovered_row");
877
878        for row_nr in row_range {
879            let y_range = Rangef::new(
880                last_header_row_y + self.get_row_top_offset(row_nr),
881                last_header_row_y + self.get_row_top_offset(row_nr + 1),
882            );
883
884            let row_x_range = self.col_x[0]..=self.col_x[self.col_x.len() - 1];
885            let row_rect = Rect::from_x_y_ranges(row_x_range, y_range).translate(-scroll_offset);
886
887            // Check if the cursor is hovering over the visible portion of this row
888            if let Some(pos) = pointer_pos {
889                let visible_row_rect = row_rect.intersect(ui.clip_rect());
890
891                // Exclusive check on bottom and right edges to prevent multi-row highlights
892                let contains_exclusive = visible_row_rect.min.x <= pos.x
893                    && pos.x < visible_row_rect.max.x
894                    && visible_row_rect.min.y <= pos.y
895                    && pos.y < visible_row_rect.max.y;
896
897                if contains_exclusive {
898                    ui.ctx()
899                        .data_mut(|d| d.insert_temp(hovered_row_id, (current_frame, row_nr)));
900                }
901            }
902
903            // Determine if the current row was hovered on this frame or the previous one
904            let row_hovered = if let Some((frame, hovered_row)) =
905                ui.ctx().data(|d| d.get_temp::<(u64, u64)>(hovered_row_id))
906            {
907                hovered_row == row_nr
908                    && (frame == current_frame || frame == current_frame.saturating_sub(1))
909            } else {
910                false
911            };
912
913            let mut row_ui = ui.new_child(
914                UiBuilder::new()
915                    .max_rect(row_rect)
916                    .id_salt(("row", row_nr))
917                    .layout(egui::Layout::left_to_right(egui::Align::Center)),
918            );
919            row_ui.set_min_size(row_rect.size());
920
921            self.table_delegate.row_ui(&mut row_ui, row_nr);
922
923            for col_nr in col_range.clone() {
924                let column = &self.table.columns[col_nr];
925                let mut cell_rect =
926                    Rect::from_x_y_ranges(self.col_x[col_nr]..=self.col_x[col_nr + 1], y_range)
927                        .translate(-scroll_offset);
928                let clip_rect = cell_rect;
929                let auto_size_this_frame = column.is_auto_size_this_frame();
930                if auto_size_this_frame {
931                    cell_rect.max.x = cell_rect.min.x + column.range.min;
932                }
933
934                let mut ui_builder = UiBuilder::new()
935                    .max_rect(cell_rect)
936                    .id_salt((row_nr, col_nr))
937                    .layout(egui::Layout::left_to_right(egui::Align::Center));
938                if auto_size_this_frame {
939                    ui_builder = ui_builder.sizing_pass();
940                }
941                let mut cell_ui = row_ui.new_child(ui_builder);
942                cell_ui.shrink_clip_rect(clip_rect);
943
944                self.table_delegate.cell_ui(
945                    &mut cell_ui,
946                    &CellInfo {
947                        col_nr,
948                        row_nr,
949                        table_id: self.id,
950                        row_hovered,
951                    },
952                );
953
954                let width = &mut self.max_column_widths[col_nr];
955                *width = width.max(cell_ui.min_size().x);
956            }
957        }
958
959        // Save column lines for later interaction:
960        for col_nr in col_range {
961            let column = &self.table.columns[col_nr];
962            if column.is_resizable() {
963                update(
964                    &mut self.visible_column_lines,
965                    col_nr,
966                    ColumnResizer {
967                        scroll_offset,
968                        top: last_header_row_y,
969                    },
970                );
971            }
972        }
973    }
974}
975
976impl SplitScrollDelegate for TableSplitScrollDelegate<'_> {
977    // First to be called
978    fn right_bottom_ui(&mut self, ui: &mut Ui, scroll_offset: Vec2) {
979        if self.table.scroll_to_columns.is_some() || self.table.scroll_to_rows.is_some() {
980            let mut target_rect = ui.clip_rect(); // no scrolling
981            let mut target_align = None;
982
983            if let Some((column_range, align)) = &self.table.scroll_to_columns {
984                // Use the first scrollable column as the base, so that offsets start
985                // at 0 for the first non-sticky column — mirroring how row_top_offset
986                // starts at 0 for the first data row.
987                let scrollable_col_x_base = self.col_x[self.table.num_sticky_cols];
988                let x_from_column_nr = |col_nr: usize| -> f32 {
989                    ui.min_rect().left() + (self.col_x[col_nr] - scrollable_col_x_base)
990                };
991
992                let sticky_width = scrollable_col_x_base - self.col_x[0];
993
994                // Subtract sticky_width from the left of the target rect so that when
995                // scroll_to_rect aligns the left of the target to the viewport left, the
996                // actual column lands just right of the sticky columns (not behind them).
997                target_rect.min.x = x_from_column_nr(*column_range.start()) - sticky_width;
998                target_rect.max.x = x_from_column_nr(*column_range.end() + 1);
999                target_align = target_align.or(*align);
1000            }
1001
1002            if let Some((row_range, align)) = &self.table.scroll_to_rows {
1003                let y_from_row_nr =
1004                    |row_nr: u64| -> f32 { ui.min_rect().top() + self.get_row_top_offset(row_nr) };
1005
1006                let last_header_row_y = self.header_row_y.last().copied().unwrap_or(0.0);
1007                let sticky_height = last_header_row_y - self.header_row_y[0];
1008
1009                // Subtract sticky_height from the top of the target rect so that when
1010                // scroll_to_rect aligns the top of the target to the viewport top, the
1011                // actual row lands just below the sticky header (not behind it).
1012                target_rect.min.y = y_from_row_nr(*row_range.start()) - sticky_height;
1013                target_rect.max.y = y_from_row_nr(*row_range.end() + 1);
1014                target_align = target_align.or(*align);
1015            }
1016
1017            ui.scroll_to_rect(target_rect, target_align);
1018        }
1019
1020        self.region_ui(ui, scroll_offset, true);
1021    }
1022
1023    fn left_top_ui(&mut self, ui: &mut Ui) {
1024        self.header_ui(ui, Vec2::ZERO);
1025    }
1026
1027    fn right_top_ui(&mut self, ui: &mut Ui, scroll_offset: Vec2) {
1028        let horizontal_scroll_offset = vec2(scroll_offset.x, 0.0);
1029        self.header_ui(ui, horizontal_scroll_offset);
1030    }
1031
1032    fn left_bottom_ui(&mut self, ui: &mut Ui, scroll_offset: Vec2) {
1033        let vertical_scroll_offset = vec2(0.0, scroll_offset.y);
1034        self.region_ui(ui, vertical_scroll_offset, false);
1035    }
1036
1037    fn paint_overlays(&mut self, ui: &mut Ui) {
1038        let total_rows_height = self.get_row_top_offset(self.table.num_rows);
1039        let header_top = self.header_row_y[0];
1040        let header_bottom = self.header_row_y.last().copied().unwrap_or(0.0);
1041        let clip_bottom = ui.clip_rect().bottom();
1042
1043        // 1. Pre-interaction pass: interact with all visible lines exactly once.
1044        // visible_column_lines contains right-body from this frame, and left-body/headers from the previous frame.
1045        for (
1046            col_nr,
1047            ColumnResizer {
1048                scroll_offset,
1049                top: _,
1050            },
1051        ) in &self.visible_column_lines
1052        {
1053            let col_nr = *col_nr;
1054            if self.col_interaction.contains_key(&col_nr) {
1055                continue; // Already interacted this frame
1056            }
1057
1058            let Some(column) = self.table.columns.get(col_nr) else {
1059                continue;
1060            };
1061            if !column.is_resizable() {
1062                continue;
1063            }
1064
1065            let column_id = column.id_for(col_nr);
1066            let range = column.range;
1067            let current = column.current;
1068            let column_width = self
1069                .state
1070                .col_widths
1071                .get(&column_id)
1072                .copied()
1073                .unwrap_or(current);
1074
1075            let x = self.col_x[col_nr + 1] - scroll_offset.x + (column_width - current);
1076            let content_bottom = header_bottom + total_rows_height - scroll_offset.y;
1077            let line_bottom = clip_bottom.min(content_bottom);
1078
1079            // Use the full line rect for interaction so dragging works seamlessly
1080            let line_rect = egui::Rect::from_x_y_ranges(x..=x, header_top..=line_bottom)
1081                .expand(ui.style().interaction.resize_grab_radius_side);
1082
1083            let column_resize_id = self.id.with(column_id).with("resize");
1084            let resize_response =
1085                ui.interact(line_rect, column_resize_id, egui::Sense::click_and_drag());
1086
1087            let hovered = resize_response.hovered();
1088            let dragged = resize_response.dragged();
1089
1090            if dragged && let Some(pointer) = ui.pointer_latest_pos() {
1091                let new_width = column_width + pointer.x - x;
1092                let clamped_width = range.clamp(new_width);
1093                self.state.col_widths.insert(column_id, clamped_width);
1094                self.dragging_col = Some(col_nr);
1095            }
1096
1097            self.col_interaction.insert(col_nr, (hovered, dragged));
1098        }
1099
1100        // 2. Paint the body lines for this quadrant
1101        for (col_nr, ColumnResizer { scroll_offset, top }) in &self.visible_column_lines {
1102            let col_nr = *col_nr;
1103            let Some(column) = self.table.columns.get(col_nr) else {
1104                continue;
1105            };
1106            if !column.is_resizable() {
1107                continue;
1108            }
1109
1110            let column_id = column.id_for(col_nr);
1111            let current = column.current;
1112            let column_width = self
1113                .state
1114                .col_widths
1115                .get(&column_id)
1116                .copied()
1117                .unwrap_or(current);
1118            let x = self.col_x[col_nr + 1] - scroll_offset.x + (column_width - current);
1119
1120            let content_bottom = header_bottom + total_rows_height - scroll_offset.y;
1121            let line_bottom = clip_bottom.min(content_bottom);
1122            let yrange = Rangef::new(*top, line_bottom);
1123
1124            let (hovered, dragged) = self
1125                .col_interaction
1126                .get(&col_nr)
1127                .copied()
1128                .unwrap_or((false, false));
1129
1130            if hovered || dragged {
1131                ui.set_cursor_icon(egui::CursorIcon::ResizeColumn);
1132            }
1133
1134            let stroke = if dragged {
1135                ui.style().visuals.widgets.active.bg_stroke
1136            } else if hovered {
1137                ui.style().visuals.widgets.hovered.bg_stroke
1138            } else {
1139                ui.visuals().widgets.noninteractive.bg_stroke
1140            };
1141
1142            ui.painter().vline(x, yrange, stroke);
1143        }
1144    }
1145
1146    fn update_col_widths(&mut self, ui: &mut Ui) {
1147        for col_nr in 0..self.table.columns.len() {
1148            // Skip auto-sizing if the user is actively dragging this column
1149            if self.dragging_col == Some(col_nr) {
1150                continue;
1151            }
1152
1153            let column = self.table.columns.get(col_nr);
1154            let Some(column) = column else {
1155                continue;
1156            };
1157            if !column.is_resizable() {
1158                continue;
1159            }
1160
1161            let column_id = column.id_for(col_nr);
1162            let used_width = column.range.clamp(self.max_column_widths[col_nr]);
1163            let old_width = self
1164                .state
1165                .col_widths
1166                .get(&column_id)
1167                .copied()
1168                .unwrap_or(column.current);
1169
1170            // Copy flags to avoid borrow checker issues
1171            let auto_size_this_frame = column.is_auto_size_this_frame();
1172            let auto_fit = column.is_auto_fit();
1173
1174            if auto_size_this_frame {
1175                self.table.columns[col_nr]
1176                    .flags
1177                    .set(ColumnFlags::AUTO_SIZE_THIS_FRAME, false);
1178            }
1179
1180            let mut new_width = old_width;
1181            if auto_size_this_frame || (ui.is_sizing_pass() && auto_fit) {
1182                new_width = used_width;
1183            } else if auto_fit {
1184                new_width = old_width.max(used_width);
1185            }
1186
1187            self.state.col_widths.insert(column_id, new_width);
1188        }
1189
1190        // Clear the interaction state for the next frame
1191        self.col_interaction.clear();
1192
1193        // Clear the drag state when the mouse button is released
1194        if !ui.input(|i| i.pointer.primary_down()) {
1195            self.dragging_col = None;
1196        }
1197    }
1198}
1199
1200/// Returns the index of the first element that returns `true` using binary search.
1201fn partition_point(range: RangeInclusive<u64>, second_partition: impl Fn(u64) -> bool) -> u64 {
1202    let mut min = *range.start();
1203    let mut max = *range.end();
1204
1205    debug_assert!(min < max, "Bad call to partition_point");
1206
1207    while min < max {
1208        let mid = min + (max - min) / 2;
1209
1210        if second_partition(mid) {
1211            max = mid;
1212        } else {
1213            min = mid + 1;
1214        }
1215    }
1216
1217    min
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use super::partition_point;
1223
1224    #[test]
1225    fn test_partition_point() {
1226        assert_eq!(partition_point(0..=17, |i| 8 <= i), 8);
1227        assert_eq!(partition_point(0..=17, |i| 9 <= i), 9);
1228        assert_eq!(partition_point(10..=17, |_| true), 10);
1229        assert_eq!(partition_point(10..=17, |_| false), 17);
1230    }
1231}