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