Skip to main content

agg_gui/widgets/table/
mod.rs

1//! `Table` — a virtualised data-table widget with header, scrolling body,
2//! striping, overlines, row-selection, sort-toggle hooks and
3//! `scroll_to_row`.
4//!
5//! Designed to mirror the shape of `egui_extras::TableBuilder` so the same
6//! mental model carries over: configure columns (`auto` / `exact` /
7//! `remainder.at_least(...).clip(...)`), describe the row set
8//! (`Homogeneous { count, height }` or `Heterogeneous { heights }`), and
9//! provide cell + header painters.  Cells are produced lazily — only rows
10//! within the visible viewport are painted, so 100 000-row demos remain
11//! cheap.
12//!
13//! The widget intentionally does *not* know about your data: it owns the
14//! chrome (backgrounds, separators, scroll, selection bookkeeping) and
15//! invokes user-supplied painters for cell content.  Cell painters get a
16//! `CellInfo` (rect, row, column, selected, font, visuals) and may use any
17//! of the project's draw primitives.
18//!
19//! Y-up note: agg-gui's coordinate system has its origin at the bottom-
20//! left.  Rows are stored top-down in the public API (row 0 visually
21//! topmost) and the widget converts to Y-up internally.
22//!
23//! Resizable columns are intentionally not implemented in this first
24//! revision; the configuration is preserved so the API doesn't break when
25//! it lands later.
26
27mod body;
28mod config;
29mod state;
30
31pub use config::{
32    distribute_widths, CellInfo, CellPainter, ColumnSize, HeaderClick, HeaderInfo, HeaderPainter,
33    RowPredicate, RowsProvider, TableColumn, TableRows, MIN_COL_W, RESIZE_HIT_HALF,
34};
35
36use std::cell::{Cell, RefCell};
37use std::collections::HashSet;
38use std::rc::Rc;
39use std::sync::Arc;
40
41use crate::color::Color;
42use crate::cursor::{set_cursor_icon, CursorIcon};
43use crate::draw_ctx::DrawCtx;
44use crate::event::{Event, EventResult, MouseButton};
45use crate::geometry::{Rect, Size};
46use crate::layout_props::WidgetBase;
47use crate::text::Font;
48use crate::widget::Widget;
49use crate::widgets::scroll_view::ScrollView;
50
51use body::TableBody;
52use state::TableState;
53
54// ── Builder ────────────────────────────────────────────────────────────────
55
56pub struct TableBuilder {
57    state: TableState,
58    columns: Vec<TableColumn>,
59    header_height: f64,
60    header_painter: Option<HeaderPainter>,
61    header_click: Option<HeaderClick>,
62    /// Forwarded to the inner `ScrollView::with_fade_color` so the
63    /// scrollbar's edge-fade gradient blends into the table's actual
64    /// ancestor background instead of the default `window_fill`.  When
65    /// the table sits in a `FlexColumn::with_panel_bg`, pass the same
66    /// panel fill colour here.
67    fade_color: Option<Color>,
68}
69
70impl Default for TableBuilder {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl TableBuilder {
77    pub fn new() -> Self {
78        Self {
79            state: TableState::defaults(),
80            columns: Vec::new(),
81            header_height: 22.0,
82            header_painter: None,
83            header_click: None,
84            fade_color: None,
85        }
86    }
87
88    /// Set the scroll-fade gradient colour on the body's inner
89    /// `ScrollView`.  Pass the visible ancestor background colour
90    /// (e.g. `Visuals::panel_fill` when the table sits on a panel) so
91    /// the fade dissolves invisibly instead of painting a bright halo
92    /// of the default `window_fill`.  See [`ScrollView::with_fade_color`].
93    pub fn fade_color(mut self, c: Color) -> Self {
94        self.fade_color = Some(c);
95        self
96    }
97
98    pub fn columns(mut self, cols: Vec<TableColumn>) -> Self {
99        self.columns = cols;
100        self
101    }
102
103    pub fn striped(self, on: bool) -> Self {
104        self.state.striped.set(on);
105        self
106    }
107    pub fn striped_cell(self, cell: Rc<Cell<bool>>) -> Self {
108        Self {
109            state: TableState {
110                striped: cell,
111                ..self.state
112            },
113            ..self
114        }
115    }
116
117    pub fn sense_click(self, on: bool) -> Self {
118        self.state.sense_click.set(on);
119        self
120    }
121    pub fn sense_click_cell(self, cell: Rc<Cell<bool>>) -> Self {
122        Self {
123            state: TableState {
124                sense_click: cell,
125                ..self.state
126            },
127            ..self
128        }
129    }
130
131    pub fn rows(self, spec: TableRows) -> Self {
132        *self.state.rows.borrow_mut() = spec;
133        self
134    }
135    pub fn rows_cell(self, cell: Rc<RefCell<TableRows>>) -> Self {
136        Self {
137            state: TableState {
138                rows: cell,
139                ..self.state
140            },
141            ..self
142        }
143    }
144    /// Install a closure that produces the current row spec.  The widget
145    /// invokes it during each layout pass and writes the result into the
146    /// internal `rows` cell, so external state changes flow in without
147    /// the caller having to manage an observer widget.
148    pub fn rows_provider(self, p: RowsProvider) -> Self {
149        *self.state.rows_provider.borrow_mut() = Some(p);
150        self
151    }
152
153    pub fn overline_pred(self, pred: RowPredicate) -> Self {
154        *self.state.overline_pred.borrow_mut() = Some(pred);
155        self
156    }
157
158    /// Use a `HashSet<usize>` as the selection mask (highlights any row
159    /// whose internal index is in the set).  Convenience over
160    /// [`Self::selection_pred`] for the common case where rows are not
161    /// reversed/transformed.
162    pub fn selection(self, sel: Rc<RefCell<HashSet<usize>>>) -> Self {
163        let pred: RowPredicate = Box::new(move |i| sel.borrow().contains(&i));
164        *self.state.selection_pred.borrow_mut() = Some(pred);
165        self
166    }
167
168    /// Pass an arbitrary predicate to compute "is row N selected?".  Useful
169    /// when display indices differ from internal indices (e.g. reversed
170    /// order) and the caller wants the highlight to track the display
171    /// index rather than the internal one.
172    pub fn selection_pred(self, pred: RowPredicate) -> Self {
173        *self.state.selection_pred.borrow_mut() = Some(pred);
174        self
175    }
176
177    pub fn resizable(self, on: bool) -> Self {
178        self.state.resizable.set(on);
179        self
180    }
181    pub fn resizable_cell(self, cell: Rc<Cell<bool>>) -> Self {
182        Self {
183            state: TableState {
184                resizable: cell,
185                ..self.state
186            },
187            ..self
188        }
189    }
190
191    /// Adopt an external `Rc<RefCell<Vec<Option<f64>>>>` as the column
192    /// overrides cell.  External code can clear it (e.g. on a Reset
193    /// button) to restore the configured widths.
194    pub fn column_overrides_cell(self, cell: Rc<RefCell<Vec<Option<f64>>>>) -> Self {
195        Self {
196            state: TableState {
197                column_overrides: cell,
198                ..self.state
199            },
200            ..self
201        }
202    }
203
204    pub fn scroll_to_row_cell(self, cell: Rc<Cell<Option<usize>>>) -> Self {
205        Self {
206            state: TableState {
207                scroll_to_row: cell,
208                ..self.state
209            },
210            ..self
211        }
212    }
213
214    pub fn scroll_offset_cell(self, cell: Rc<Cell<f64>>) -> Self {
215        Self {
216            state: TableState {
217                scroll_offset: cell,
218                ..self.state
219            },
220            ..self
221        }
222    }
223
224    pub fn header_height(mut self, h: f64) -> Self {
225        self.header_height = h;
226        self
227    }
228    pub fn header_painter(mut self, p: HeaderPainter) -> Self {
229        self.header_painter = Some(p);
230        self
231    }
232    pub fn header_click(mut self, p: HeaderClick) -> Self {
233        self.header_click = Some(p);
234        self
235    }
236
237    pub fn cell_painter(self, p: CellPainter) -> Self {
238        *self.state.cell_painter.borrow_mut() = Some(p);
239        self
240    }
241
242    pub fn on_row_click(self, f: Box<dyn FnMut(usize, usize)>) -> Self {
243        *self.state.on_row_click.borrow_mut() = Some(f);
244        self
245    }
246
247    /// Materialise the configured table into a widget.
248    pub fn build(self, font: Arc<Font>) -> Table {
249        let body = TableBody {
250            bounds: Rect::default(),
251            children: Vec::new(),
252            font: Arc::clone(&font),
253            state: self.state.clone(),
254        };
255        let mut scroll = ScrollView::new(Box::new(body))
256            .vertical(true)
257            .horizontal(true)
258            .with_offset_cell(Rc::clone(&self.state.scroll_offset))
259            .with_h_offset_cell(Rc::clone(&self.state.h_offset))
260            .with_viewport_cell(Rc::clone(&self.state.viewport_cell));
261        if let Some(c) = self.fade_color {
262            scroll = scroll.with_fade_color(c);
263        }
264
265        let n = self.columns.len();
266        self.state.column_overrides.borrow_mut().resize(n, None);
267        Table {
268            bounds: Rect::default(),
269            children: vec![Box::new(scroll)],
270            base: WidgetBase::new(),
271            font,
272            columns: self.columns,
273            state: self.state,
274            header_height: self.header_height,
275            header_painter: RefCell::new(self.header_painter),
276            header_click: RefCell::new(self.header_click),
277            drag_resize: Cell::new(None),
278        }
279    }
280}
281
282// ── Table widget ────────────────────────────────────────────────────────────
283
284pub struct Table {
285    bounds: Rect,
286    children: Vec<Box<dyn Widget>>, // [0] = ScrollView wrapping the body widget.
287    base: WidgetBase,
288    font: Arc<Font>,
289    columns: Vec<TableColumn>,
290    state: TableState,
291    header_height: f64,
292    header_painter: RefCell<Option<HeaderPainter>>,
293    header_click: RefCell<Option<HeaderClick>>,
294    /// Active column resize drag: (column_index, pointer_x_at_down, original_width).
295    drag_resize: Cell<Option<(usize, f64, f64)>>,
296}
297
298impl Table {
299    /// Begin configuring a new table.  Convenience for [`TableBuilder::new`].
300    pub fn builder() -> TableBuilder {
301        TableBuilder::new()
302    }
303
304    /// Reset every column's user-resized override back to its configured
305    /// `auto`/`exact`/`remainder` width.
306    pub fn reset_column_widths(&self) {
307        self.state.column_overrides.borrow_mut().clear();
308    }
309
310    /// Read-only handle to the column overrides — `None` for the
311    /// configured default, `Some(w)` for user-resized columns.  Useful for
312    /// persistence layers that want to save and restore the layout.
313    pub fn column_overrides(&self) -> Rc<RefCell<Vec<Option<f64>>>> {
314        Rc::clone(&self.state.column_overrides)
315    }
316
317    /// Replace the row set in place.  Useful for switching between
318    /// homogeneous/heterogeneous modes without rebuilding the widget.
319    pub fn set_rows(&self, rows: TableRows) {
320        *self.state.rows.borrow_mut() = rows;
321    }
322
323    /// Read-only access to the current top-down row set spec.
324    pub fn rows_handle(&self) -> Rc<RefCell<TableRows>> {
325        Rc::clone(&self.state.rows)
326    }
327
328    pub fn margin(mut self, m: crate::layout_props::Insets) -> Self {
329        self.base.margin = m;
330        self
331    }
332}
333
334impl Widget for Table {
335    fn type_name(&self) -> &'static str {
336        "Table"
337    }
338    fn bounds(&self) -> Rect {
339        self.bounds
340    }
341    fn set_bounds(&mut self, b: Rect) {
342        self.bounds = b;
343    }
344    fn children(&self) -> &[Box<dyn Widget>] {
345        &self.children
346    }
347    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
348        &mut self.children
349    }
350
351    fn margin(&self) -> crate::layout_props::Insets {
352        self.base.margin
353    }
354
355    fn layout(&mut self, available: Size) -> Size {
356        let w = available.width.max(40.0);
357        let h = available.height.max(40.0);
358        self.bounds = Rect::new(0.0, 0.0, w, h);
359
360        // Pull live row spec from the provider closure (if any) before any
361        // measurement that depends on row count or heights.
362        if let Some(p) = self.state.rows_provider.borrow().as_ref() {
363            let new_rows = p();
364            *self.state.rows.borrow_mut() = new_rows;
365        }
366
367        // Apply any pending scroll-to-row before laying out the scroll view.
368        if let Some(target) = self.state.scroll_to_row.get() {
369            self.state.scroll_to_row.set(None);
370            let rows = self.state.rows.borrow();
371            let n = rows.count();
372            if n > 0 {
373                let target = target.min(n - 1);
374                self.state.scroll_offset.set(rows.top_down_y_at(target));
375            }
376        }
377
378        // Compute column widths & publish.  The natural content width
379        // (sum of column widths) may exceed the table's own width when
380        // the user has resized columns wider than the viewport — the
381        // body's ScrollView handles horizontal panning in that case.
382        let overrides = self.state.column_overrides.borrow().clone();
383        let widths = distribute_widths(&self.columns, w, &overrides);
384        let content_w: f64 = widths.iter().sum();
385        *self.state.widths.borrow_mut() = widths;
386        self.state.content_w.set(content_w);
387
388        // Body / scroll view fills the area below the header.
389        let body_h = (h - self.header_height).max(0.0);
390        let scroll = &mut self.children[0];
391        scroll.layout(Size::new(w, body_h));
392        scroll.set_bounds(Rect::new(0.0, 0.0, w, body_h));
393
394        Size::new(w, h)
395    }
396
397    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
398        let v = ctx.visuals();
399        let widths = self.state.widths.borrow().clone();
400        let header_y = self.bounds.height - self.header_height;
401        let h = self.header_height;
402        let viewport_w = self.bounds.width;
403        let h_offset = self.state.h_offset.get();
404
405        // Header background spans the visible viewport width — the
406        // backdrop is always opaque even when the table content is
407        // wider than the viewport.
408        ctx.set_fill_color(Color::rgba(0.5, 0.5, 0.5, 0.10));
409        ctx.begin_path();
410        ctx.rect(0.0, header_y, viewport_w, h);
411        ctx.fill();
412
413        // Header bottom border.
414        ctx.set_stroke_color(v.separator);
415        ctx.set_line_width(1.0);
416        ctx.begin_path();
417        ctx.move_to(0.0, header_y);
418        ctx.line_to(viewport_w, header_y);
419        ctx.stroke();
420
421        // Clip and translate so column-cell painters and separators draw
422        // in CONTENT space (origin = leftmost column edge) but render
423        // only within the visible header strip.
424        ctx.save();
425        ctx.clip_rect(0.0, header_y, viewport_w, h);
426        ctx.translate(-h_offset, 0.0);
427
428        // Per-column header painters.
429        if let Some(painter) = self.header_painter.borrow_mut().as_mut() {
430            let mut x = 0.0;
431            for (col, &cw) in widths.iter().enumerate() {
432                let info = HeaderInfo {
433                    col,
434                    rect: Rect::new(x, header_y, cw, h),
435                    visuals: &v,
436                    font: &self.font,
437                };
438                ctx.save();
439                ctx.clip_rect(x, header_y, cw, h);
440                painter(&info, ctx);
441                ctx.restore();
442                x += cw;
443            }
444        }
445
446        // Vertical column separators across the header.  Resizable column
447        // edges get a slightly thicker line so users can see where to grab.
448        let mut sx = 0.0;
449        let dragging = self.drag_resize.get().map(|(c, _, _)| c);
450        for (i, &cw) in widths.iter().enumerate() {
451            sx += cw;
452            if i + 1 < widths.len() {
453                let is_resizable = self.columns.get(i).map(|c| c.resizable).unwrap_or(false)
454                    && self.state.resizable.get();
455                let is_active = dragging == Some(i);
456                let color = if is_active {
457                    v.accent
458                } else if is_resizable {
459                    Color::rgba(v.separator.r, v.separator.g, v.separator.b, 0.9)
460                } else {
461                    v.separator
462                };
463                ctx.set_stroke_color(color);
464                ctx.set_line_width(if is_active { 2.0 } else { 1.0 });
465                ctx.begin_path();
466                ctx.move_to(sx, header_y);
467                ctx.line_to(sx, header_y + h);
468                ctx.stroke();
469            }
470        }
471        ctx.restore();
472    }
473
474    fn on_event(&mut self, event: &Event) -> EventResult {
475        let header_y = self.bounds.height - self.header_height;
476        let in_header = |y: f64| y >= header_y && y <= header_y + self.header_height;
477        let h_offset = self.state.h_offset.get();
478
479        // Helper: which (resizable) column edge — if any — is the pointer
480        // (in CONTENT space, i.e. with the horizontal scroll offset
481        // already added back) within `RESIZE_HIT_HALF` of?  Used both
482        // for cursor change on hover and for starting a drag.
483        let resize_target_at = |content_x: f64, y: f64| -> Option<(usize, f64)> {
484            if !in_header(y) || !self.state.resizable.get() {
485                return None;
486            }
487            let widths = self.state.widths.borrow().clone();
488            let mut acc = 0.0;
489            for (col, &cw) in widths.iter().enumerate() {
490                let edge = acc + cw;
491                let last = col + 1 == widths.len();
492                let resizable = self.columns.get(col).map(|c| c.resizable).unwrap_or(false);
493                if !last && resizable && (content_x - edge).abs() <= RESIZE_HIT_HALF {
494                    return Some((col, cw));
495                }
496                acc += cw;
497            }
498            None
499        };
500
501        // Active resize drag — must be checked first so MouseMove stays
502        // routed to us via the framework's mouse-capture path.
503        if let Some((col, content_x0, w0)) = self.drag_resize.get() {
504            match event {
505                Event::MouseMove { pos } => {
506                    set_cursor_icon(CursorIcon::ResizeHorizontal);
507                    let content_x = pos.x + h_offset;
508                    let dx = content_x - content_x0;
509                    let new_w = (w0 + dx).max(MIN_COL_W);
510                    let mut overs = self.state.column_overrides.borrow_mut();
511                    if overs.len() <= col {
512                        overs.resize(col + 1, None);
513                    }
514                    overs[col] = Some(new_w);
515                    crate::animation::request_draw();
516                    return EventResult::Consumed;
517                }
518                Event::MouseUp {
519                    button: MouseButton::Left,
520                    ..
521                } => {
522                    self.drag_resize.set(None);
523                    crate::animation::request_draw();
524                    return EventResult::Consumed;
525                }
526                _ => {}
527            }
528        }
529
530        // Hover affordance for resize handles — only meaningful when not
531        // already dragging (handled above).
532        if let Event::MouseMove { pos } = event {
533            let content_x = pos.x + h_offset;
534            if resize_target_at(content_x, pos.y).is_some() {
535                set_cursor_icon(CursorIcon::ResizeHorizontal);
536                return EventResult::Consumed;
537            }
538        }
539
540        if let Event::MouseDown {
541            pos,
542            button: MouseButton::Left,
543            ..
544        } = event
545        {
546            let content_x = pos.x + h_offset;
547            if let Some((col, cw)) = resize_target_at(content_x, pos.y) {
548                // SNAPSHOT every column's current width into overrides at
549                // drag-start.  Without this, dragging one Remainder column
550                // would leak space taken/given into the other Remainder
551                // columns via re-distribution; the user expects only the
552                // dragged edge to move.  Sized columns already had their
553                // override (or are fixed) — pinning everything ensures
554                // distribute_widths returns the same vector after the
555                // drag for non-target columns.
556                {
557                    let widths = self.state.widths.borrow().clone();
558                    let mut overs = self.state.column_overrides.borrow_mut();
559                    overs.resize(widths.len(), None);
560                    for (j, &w) in widths.iter().enumerate() {
561                        if overs[j].is_none() {
562                            overs[j] = Some(w);
563                        }
564                    }
565                }
566                self.drag_resize.set(Some((col, content_x, cw)));
567                set_cursor_icon(CursorIcon::ResizeHorizontal);
568                crate::animation::request_draw();
569                return EventResult::Consumed;
570            }
571        }
572
573        if let Event::MouseUp {
574            pos,
575            button: MouseButton::Left,
576            ..
577        } = event
578        {
579            if in_header(pos.y) {
580                let widths = self.state.widths.borrow().clone();
581                let content_x = pos.x + h_offset;
582                let mut x = 0.0;
583                for (col, cw) in widths.iter().enumerate() {
584                    if content_x >= x && content_x < x + cw {
585                        let local_x = content_x - x;
586                        let local_y = pos.y - header_y;
587                        if let Some(cb) = self.header_click.borrow_mut().as_mut() {
588                            let r = cb(col, local_x, local_y);
589                            if r == EventResult::Consumed {
590                                crate::animation::request_draw();
591                            }
592                            return r;
593                        }
594                        return EventResult::Ignored;
595                    }
596                    x += cw;
597                }
598            }
599        }
600        EventResult::Ignored
601    }
602}
603
604// ── Helpers exposed to users for cell painters ──────────────────────────────
605
606/// Trim `text` from the end with an ellipsis until it fits `max_w`, using
607/// the current font/size on `ctx`.
608pub fn clip_text_to_width(ctx: &dyn DrawCtx, text: &str, max_w: f64) -> String {
609    if let Some(m) = ctx.measure_text(text) {
610        if m.width <= max_w {
611            return text.to_string();
612        }
613    }
614    let mut out = text.to_string();
615    let ell = "…";
616    while !out.is_empty() {
617        out.pop();
618        let candidate = format!("{out}{ell}");
619        if let Some(m) = ctx.measure_text(&candidate) {
620            if m.width <= max_w {
621                return candidate;
622            }
623        }
624    }
625    String::new()
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn distribute_widths_splits_remainders_equally() {
634        let cols = vec![
635            TableColumn::auto(50.0),
636            TableColumn::remainder().at_least(40.0),
637            TableColumn::auto(60.0),
638            TableColumn::remainder(),
639            TableColumn::remainder(),
640        ];
641        let widths = distribute_widths(&cols, 410.0, &[]);
642        assert_eq!(widths[0], 50.0);
643        assert_eq!(widths[2], 60.0);
644        assert!((widths[1] - 100.0).abs() < 0.001);
645        assert!((widths[3] - 100.0).abs() < 0.001);
646        assert!((widths[4] - 100.0).abs() < 0.001);
647    }
648
649    #[test]
650    fn distribute_widths_respects_at_least() {
651        let cols = vec![
652            TableColumn::auto(200.0),
653            TableColumn::remainder().at_least(40.0),
654        ];
655        let widths = distribute_widths(&cols, 100.0, &[]);
656        assert!(widths[1] >= 40.0);
657    }
658
659    #[test]
660    fn distribute_widths_pins_overrides_and_redistributes_remainders() {
661        let cols = vec![
662            TableColumn::auto(50.0),
663            TableColumn::remainder().at_least(20.0),
664            TableColumn::remainder().at_least(20.0),
665            TableColumn::remainder().at_least(20.0),
666        ];
667        // User dragged column 1 to 200 px wide.
668        let overrides = vec![None, Some(200.0), None, None];
669        let widths = distribute_widths(&cols, 500.0, &overrides);
670        assert_eq!(widths[0], 50.0);
671        assert!((widths[1] - 200.0).abs() < 0.001);
672        // Remaining 250 split between cols 2 and 3 = 125 each.
673        assert!((widths[2] - 125.0).abs() < 0.001);
674        assert!((widths[3] - 125.0).abs() < 0.001);
675    }
676
677    #[test]
678    fn distribute_widths_clamps_override_min() {
679        let cols = vec![
680            TableColumn::auto(100.0),
681            TableColumn::remainder().at_least(20.0),
682        ];
683        let widths = distribute_widths(&cols, 200.0, &[Some(2.0), None]);
684        assert!(widths[0] >= MIN_COL_W);
685    }
686
687    #[test]
688    fn rows_homogeneous_total() {
689        let r = TableRows::Homogeneous {
690            count: 5,
691            height: 10.0,
692        };
693        assert_eq!(r.total_height(), 50.0);
694        assert_eq!(r.height_at(3), 10.0);
695        assert_eq!(r.top_down_y_at(2), 20.0);
696    }
697
698    #[test]
699    fn rows_heterogeneous_total() {
700        let r = TableRows::Heterogeneous {
701            heights: vec![10.0, 20.0, 30.0],
702        };
703        assert_eq!(r.total_height(), 60.0);
704        assert_eq!(r.top_down_y_at(2), 30.0);
705    }
706}