Skip to main content

oxframe_components/
table.rs

1//! A table with a header and rows that are added, removed and sorted with
2//! animation.
3
4use std::cell::RefCell;
5use std::cmp::Ordering;
6use std::fmt::Display;
7use std::future::Future;
8use std::ops::Deref;
9use std::rc::Rc;
10
11use oxframe_2d::{
12    BaseProps, Group, Line, Muted, Node, NodeBuilder, NodeId, Rect, Surface, Text, Txt,
13};
14use oxframe_core::{Tween, all};
15
16/// One row of a [`Table`]: a group with a background, its cells and a rule
17/// under it. Derefs to [`Node`].
18#[derive(Clone)]
19pub struct TableRow {
20    group: Group,
21    cells: Vec<Txt>,
22    background: Rect,
23}
24
25impl TableRow {
26    /// The cells, left to right.
27    pub fn cells(&self) -> Vec<Txt> {
28        self.cells.clone()
29    }
30
31    /// One cell.
32    ///
33    /// # Panics
34    ///
35    /// If the table has no such column.
36    pub fn cell(&self, column: usize) -> Txt {
37        self.cells[column]
38    }
39
40    /// The row's background, hidden at first: `row.background().fade_in()`
41    /// highlights the row.
42    pub fn background(&self) -> Rect {
43        self.background
44    }
45
46    /// The text of each cell now.
47    pub fn values(&self) -> Vec<String> {
48        self.cells.iter().map(|c| c.text().get()).collect()
49    }
50
51    /// The row's group.
52    pub fn node(&self) -> Node {
53        *self.group
54    }
55}
56
57impl Deref for TableRow {
58    type Target = Node;
59    fn deref(&self) -> &Node {
60        &self.group
61    }
62}
63
64struct Layout {
65    xs: Vec<f64>,
66    width: f64,
67    row_height: f64,
68    /// Center y of the first data row.
69    top: f64,
70    font_size: Option<f64>,
71}
72
73impl Layout {
74    fn text(&self, text: String) -> oxframe_2d::TxtBuilder {
75        let b = Txt::new().text(text).body();
76        match self.font_size {
77            Some(px) => b.font_size(px),
78            None => b,
79        }
80    }
81}
82
83impl Layout {
84    fn y(&self, i: usize) -> f64 {
85        self.top + i as f64 * self.row_height
86    }
87}
88
89/// A header and rows. The rows are a list you can change while the scene
90/// runs: [`insert_row`](Self::insert_row), [`remove_row`](Self::remove_row)
91/// and [`sort_by_column`](Self::sort_by_column) move the other rows into
92/// place.
93///
94/// ```ignore
95/// let t = view.add(Table::new(["Renderer", "Frame"]).row(["vello", "4 ms"]).row(["skia", "9 ms"]));
96/// t.insert_row(1, ["cairo", "14 ms"]).await;
97/// t.sort_by_column(1, 0.8).await;
98/// ```
99#[derive(Clone)]
100pub struct Table {
101    root: Group,
102    header: Vec<Txt>,
103    rows: Rc<RefCell<Vec<TableRow>>>,
104    layout: Rc<Layout>,
105}
106
107impl Table {
108    /// A table with these column titles. Add rows with
109    /// [`row`](TableBuilder::row).
110    #[allow(clippy::new_ret_no_self)]
111    pub fn new<V: Display>(header: impl IntoIterator<Item = V>) -> TableBuilder {
112        TableBuilder {
113            base: BaseProps::default(),
114            header: header.into_iter().map(|v| v.to_string()).collect(),
115            rows: Vec::new(),
116            widths: Vec::new(),
117            row_height: 64.0,
118            font_size: None,
119        }
120    }
121
122    /// The column titles.
123    pub fn header(&self) -> Vec<Txt> {
124        self.header.clone()
125    }
126
127    /// The row shown at position `i` (0 is the first under the header).
128    ///
129    /// # Panics
130    ///
131    /// If there is no such row.
132    pub fn row(&self, i: usize) -> TableRow {
133        self.rows.borrow()[i].clone()
134    }
135
136    /// The cell at row `row`, column `column`, by the current order.
137    pub fn cell(&self, row: usize, column: usize) -> Txt {
138        self.row(row).cell(column)
139    }
140
141    /// Number of rows, without the header.
142    pub fn len(&self) -> usize {
143        self.rows.borrow().len()
144    }
145
146    /// The table has no rows.
147    pub fn is_empty(&self) -> bool {
148        self.rows.borrow().is_empty()
149    }
150
151    /// Adds a row at position `at` (clamped to the end): the rows below
152    /// slide down and the new one fades in. Await it.
153    pub fn insert_row<V: Display>(
154        &self,
155        at: usize,
156        cells: impl IntoIterator<Item = V>,
157    ) -> impl Future<Output = ()> + 'static {
158        let at = at.min(self.len());
159        let values: Vec<String> = cells.into_iter().map(|v| v.to_string()).collect();
160        let row = make_row(&self.root, &self.layout, &values, self.layout.y(at), 0.0);
161        let mut moves: Vec<Tween<f64>> = Vec::new();
162        {
163            let mut rows = self.rows.borrow_mut();
164            rows.insert(at, row.clone());
165            for (i, r) in rows.iter().enumerate().skip(at + 1) {
166                moves.push(r.y().to(self.layout.y(i), 0.4));
167            }
168        }
169        async move {
170            all(moves).await;
171            row.opacity().to(1.0, 0.3).await;
172        }
173    }
174
175    /// Adds a row at the end.
176    pub fn push_row<V: Display>(
177        &self,
178        cells: impl IntoIterator<Item = V>,
179    ) -> impl Future<Output = ()> + 'static {
180        self.insert_row(usize::MAX, cells)
181    }
182
183    /// Fades out row `i` and slides the rows below up. Await it.
184    ///
185    /// # Panics
186    ///
187    /// If there is no such row.
188    pub fn remove_row(&self, i: usize) -> impl Future<Output = ()> + 'static {
189        let row = self.rows.borrow_mut().remove(i);
190        let fade = row.opacity().to(0.0, 0.3);
191        let moves: Vec<Tween<f64>> = self
192            .rows
193            .borrow()
194            .iter()
195            .enumerate()
196            .skip(i)
197            .map(|(k, r)| r.y().to(self.layout.y(k), 0.4))
198            .collect();
199        async move {
200            fade.await;
201            all(moves).await;
202            row.remove();
203        }
204    }
205
206    /// Reorders the rows by `compare` on their current values and moves
207    /// each one to its new place over `duration` seconds. The order is
208    /// stable: equal rows keep theirs.
209    pub fn sort_by(
210        &self,
211        compare: impl Fn(&[String], &[String]) -> Ordering,
212        duration: f64,
213    ) -> impl Future<Output = ()> + 'static {
214        let mut rows = self.rows.borrow_mut();
215        let mut keyed: Vec<(Vec<String>, TableRow)> =
216            rows.drain(..).map(|r| (r.values(), r)).collect();
217        keyed.sort_by(|a, b| compare(&a.0, &b.0));
218        rows.extend(keyed.into_iter().map(|(_, r)| r));
219        let moves: Vec<Tween<f64>> = rows
220            .iter()
221            .enumerate()
222            .map(|(i, r)| r.y().to(self.layout.y(i), duration))
223            .collect();
224        async move {
225            all(moves).await;
226        }
227    }
228
229    /// Sorts by one column, smallest first. Cells that start with a number
230    /// (`"12 ms"`, `"-3.5"`) compare as numbers, the rest as text.
231    pub fn sort_by_column(
232        &self,
233        column: usize,
234        duration: f64,
235    ) -> impl Future<Output = ()> + 'static {
236        self.sort_by(
237            move |a, b| compare_cells(a.get(column), b.get(column)),
238            duration,
239        )
240    }
241
242    /// The untyped node handle.
243    pub fn node(&self) -> Node {
244        *self.root
245    }
246}
247
248impl Deref for Table {
249    type Target = Node;
250    fn deref(&self) -> &Node {
251        &self.root
252    }
253}
254
255/// The number a cell starts with, such as 12 in `"12 ms"`.
256fn leading_number(s: &str) -> Option<f64> {
257    let s = s.trim();
258    let end = s
259        .char_indices()
260        .take_while(|&(i, c)| c.is_ascii_digit() || c == '.' || ((c == '-' || c == '+') && i == 0))
261        .map(|(i, c)| i + c.len_utf8())
262        .last()?;
263    s[..end].parse().ok()
264}
265
266fn compare_cells(a: Option<&String>, b: Option<&String>) -> Ordering {
267    let (a, b) = (a.map_or("", String::as_str), b.map_or("", String::as_str));
268    match (leading_number(a), leading_number(b)) {
269        (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
270        _ => a.cmp(b),
271    }
272}
273
274fn make_row(root: &Group, layout: &Layout, values: &[String], y: f64, opacity: f64) -> TableRow {
275    let group = root.add(Group::new().y(y).opacity(opacity).name("row"));
276    let background = group.add(
277        Rect::new()
278            .width(layout.width)
279            .height(layout.row_height - 4.0)
280            .radius(8.0)
281            .fill(Surface)
282            .opacity(0.0)
283            .name("row_background"),
284    );
285    let cells = layout
286        .xs
287        .iter()
288        .enumerate()
289        .map(|(c, &x)| {
290            let text = values.get(c).cloned().unwrap_or_default();
291            group.add(layout.text(text).x(x).name(format!("cell{c}")))
292        })
293        .collect();
294    let h = layout.row_height / 2.0;
295    group.add(
296        Line::new()
297            .points([(-layout.width / 2.0, h), (layout.width / 2.0, h)])
298            .stroke(Muted)
299            .line_width(1.0)
300            .name("rule"),
301    );
302    TableRow {
303        group,
304        cells,
305        background,
306    }
307}
308
309/// Builder for [`Table`].
310#[must_use = "a builder does nothing until it is passed to `add`"]
311pub struct TableBuilder {
312    base: BaseProps,
313    header: Vec<String>,
314    rows: Vec<Vec<String>>,
315    widths: Vec<f64>,
316    row_height: f64,
317    font_size: Option<f64>,
318}
319
320impl TableBuilder {
321    /// A row of values under the header.
322    pub fn row<V: Display>(mut self, cells: impl IntoIterator<Item = V>) -> Self {
323        self.rows
324            .push(cells.into_iter().map(|v| v.to_string()).collect());
325        self
326    }
327
328    /// Column widths in pixels. By default each column fits the widest of
329    /// its header and the starting rows; set widths when rows added later
330    /// are wider.
331    pub fn widths(mut self, px: impl IntoIterator<Item = f64>) -> Self {
332        self.widths = px.into_iter().collect();
333        self
334    }
335
336    /// Height of every row, header included. Default 64.
337    pub fn row_height(mut self, px: f64) -> Self {
338        self.row_height = px;
339        self
340    }
341
342    /// Size of the text in pixels. Default: the theme's body size. Raise
343    /// [`row_height`](Self::row_height) with it.
344    pub fn font_size(mut self, px: f64) -> Self {
345        self.font_size = Some(px);
346        self
347    }
348}
349
350impl NodeBuilder for TableBuilder {
351    type Handle = Table;
352    fn base(&mut self) -> &mut BaseProps {
353        &mut self.base
354    }
355    fn build(self, _: &oxframe_2d::SceneGraph, parent: NodeId) -> Table {
356        let base = self.base;
357        let mut group = Group::new().x(base.x).y(base.y).opacity(base.opacity);
358        if let Some(name) = base.name {
359            group = group.name(name);
360        }
361        let root = Node::from_id(parent).add(group);
362        let columns = self
363            .rows
364            .iter()
365            .map(Vec::len)
366            .chain([self.header.len()])
367            .max()
368            .unwrap_or(0);
369        let font_size = self.font_size;
370        let text = move |t: String| {
371            let b = Txt::new().text(t).body();
372            match font_size {
373                Some(px) => b.font_size(px),
374                None => b,
375            }
376        };
377        // Header cells first: their sizes and those of the rows set the widths.
378        let header: Vec<Txt> = (0..columns)
379            .map(|c| {
380                let t = self.header.get(c).cloned().unwrap_or_default();
381                root.add(text(t).fill(Muted).name(format!("head{c}")))
382            })
383            .collect();
384        let mut widths: Vec<f64> = header.iter().map(|t| t.size().0).collect();
385        for row in &self.rows {
386            for (c, v) in row.iter().enumerate() {
387                let probe = root.add(text(v.clone()));
388                widths[c] = widths[c].max(probe.size().0);
389                probe.remove();
390            }
391        }
392        for (c, w) in self.widths.iter().enumerate().take(columns) {
393            widths[c] = *w;
394        }
395        let pad = 48.0;
396        let width = widths.iter().map(|w| w + pad).sum::<f64>();
397        let mut xs = Vec::with_capacity(columns);
398        let mut left = -width / 2.0;
399        for w in &widths {
400            xs.push(left + (w + pad) / 2.0);
401            left += w + pad;
402        }
403        let rh = self.row_height;
404        let total = rh * (self.rows.len() + 1) as f64;
405        let head_y = -total / 2.0 + rh / 2.0;
406        for (t, x) in header.iter().zip(&xs) {
407            t.x().set(*x);
408            t.y().set(head_y);
409        }
410        root.add(
411            Line::new()
412                .points([
413                    (-width / 2.0, head_y + rh / 2.0),
414                    (width / 2.0, head_y + rh / 2.0),
415                ])
416                .stroke(Text)
417                .line_width(2.0)
418                .name("header_rule"),
419        );
420        let layout = Rc::new(Layout {
421            xs,
422            width,
423            row_height: rh,
424            top: head_y + rh,
425            font_size,
426        });
427        let rows = self
428            .rows
429            .iter()
430            .enumerate()
431            .map(|(i, values)| make_row(&root, &layout, values, layout.y(i), 1.0))
432            .collect();
433        Table {
434            root,
435            header,
436            rows: Rc::new(RefCell::new(rows)),
437            layout,
438        }
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use oxframe_2d::{SceneGraph, View};
446    use oxframe_core::Executor;
447
448    fn table(graph: &Rc<SceneGraph>) -> Table {
449        graph.enter(|| {
450            View::current().add(
451                Table::new(["Renderer", "Frame"])
452                    .row(["vello", "4 ms"])
453                    .row(["skia", "12 ms"])
454                    .row(["cairo", "9 ms"]),
455            )
456        })
457    }
458
459    #[test]
460    fn numbers_compare_as_numbers() {
461        assert_eq!(leading_number("12 ms"), Some(12.0));
462        assert_eq!(leading_number("-3.5"), Some(-3.5));
463        assert_eq!(leading_number("ms"), None);
464        let (a, b) = ("9 ms".to_string(), "12 ms".to_string());
465        assert_eq!(compare_cells(Some(&a), Some(&b)), Ordering::Less);
466        let (a, b) = ("b".to_string(), "a".to_string());
467        assert_eq!(compare_cells(Some(&a), Some(&b)), Ordering::Greater);
468    }
469
470    #[test]
471    fn rows_sit_under_the_header_in_order() {
472        let graph = SceneGraph::new(1920, 1080);
473        let t = table(&graph);
474        graph.enter(|| {
475            assert_eq!(t.len(), 3);
476            assert!(t.header()[0].y().get() < t.row(0).y().get());
477            assert!(t.row(0).y().get() < t.row(1).y().get());
478            assert_eq!(t.cell(2, 0).text().get(), "cairo");
479            assert_eq!(t.row(1).cell(0).x().get(), t.header()[0].x().get());
480        });
481    }
482
483    #[test]
484    fn sorting_insertion_and_removal_move_rows_into_place() {
485        let graph = SceneGraph::new(1920, 1080);
486        let t = table(&graph);
487        let ys: Vec<f64> = graph.enter(|| (0..3).map(|i| t.row(i).y().get()).collect());
488        let t2 = t.clone();
489        graph.enter(|| {
490            let mut ex = Executor::new(
491                60.0,
492                oxframe_protocol::Meta::default(),
493                0,
494                std::pin::Pin::from(Box::new(async move {
495                    t2.sort_by_column(1, 0.5).await;
496                    t2.insert_row(0, ["wgpu", "2 ms"]).await;
497                    t2.remove_row(3).await;
498                }) as Box<dyn Future<Output = ()>>),
499            );
500            while !ex.step() {}
501            let names: Vec<String> = (0..t.len()).map(|i| t.cell(i, 0).text().get()).collect();
502            assert_eq!(names, ["wgpu", "vello", "cairo"]);
503            for (i, y) in ys.iter().enumerate() {
504                assert!((t.row(i).y().get() - y).abs() < 1e-6, "row {i}");
505            }
506            assert_eq!(t.row(0).opacity().get(), 1.0);
507        });
508    }
509}