Skip to main content

ravenrs_widgets/
data.rs

1//! Data display: lists and tables.
2
3use std::fmt;
4use std::rc::Rc;
5
6use ravenrs_core::{Border, Color, CursorIcon, Insets, Role};
7use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
8use ravenrs_style::{Semantic, TextStyle, Weight};
9
10use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
11use crate::{Avatar, IconName, View, ViewSeq, util};
12
13/// A bordered list; rows are separated by hairlines.
14pub struct List<C> {
15    rows: C,
16}
17
18impl<C> fmt::Debug for List<C> {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        f.write_str("List")
21    }
22}
23
24#[cfg_attr(feature = "live", ravenrs_live::expose)]
25impl<C: ViewSeq> List<C> {
26    /// A list of `rows`, usually [`ListItem`]s.
27    pub fn new(rows: C) -> Self {
28        Self { rows }
29    }
30}
31
32impl<C: ViewSeq> View for List<C> {
33    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
34        let theme = *cx.theme();
35        let rows = cx.children(&self.rows);
36        let mut children = Vec::with_capacity(rows.len() * 2);
37        for (i, row) in rows.into_iter().enumerate() {
38            if i > 0 {
39                let line = cx.child(&crate::Divider::horizontal());
40                cx.semantics(line, Semantics::hidden());
41                children.push(line);
42            }
43            cx.semantics_if_none(row, Semantics::new(Role::ListItem));
44            children.push(row);
45        }
46        let style = LayoutStyle {
47            padding: Insets::uniform(4.0),
48            align: Align::Stretch,
49            ..LayoutStyle::stack(Axis::Vertical)
50        };
51        let paint = QuadPaint {
52            corner_radius: theme.radii.lg,
53            border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
54            ..QuadPaint::new(theme.color(Semantic::Surface))
55        };
56        let node = cx.container(style, &children, Paint::Quad(paint));
57        cx.semantics(node, Semantics::new(Role::List));
58        node
59    }
60}
61
62enum Leading {
63    None,
64    Icon(IconName),
65    Avatar(String),
66}
67
68/// A list row: leading icon or avatar, title, subtitle and trailing text.
69#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
70pub struct ListItem {
71    title: String,
72    subtitle: Option<String>,
73    leading: Leading,
74    trailing: Option<String>,
75    selected: bool,
76    on_click: Option<Rc<dyn Fn()>>,
77}
78
79impl fmt::Debug for ListItem {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.debug_struct("ListItem").field("title", &self.title).finish_non_exhaustive()
82    }
83}
84
85#[cfg_attr(feature = "live", ravenrs_live::expose)]
86impl ListItem {
87    /// A row with this title.
88    pub fn new(title: impl Into<String>) -> Self {
89        Self {
90            title: title.into(),
91            subtitle: None,
92            leading: Leading::None,
93            trailing: None,
94            selected: false,
95            on_click: None,
96        }
97    }
98
99    /// A muted second line under the title.
100    pub fn subtitle(mut self, text: impl Into<String>) -> Self {
101        self.subtitle = Some(text.into());
102        self
103    }
104
105    /// A leading icon.
106    pub fn icon(mut self, icon: IconName) -> Self {
107        self.leading = Leading::Icon(icon);
108        self
109    }
110
111    /// A leading avatar showing the initials of `name`.
112    pub fn avatar(mut self, name: impl Into<String>) -> Self {
113        self.leading = Leading::Avatar(name.into());
114        self
115    }
116
117    /// Muted text at the end of the row.
118    pub fn trailing(mut self, text: impl Into<String>) -> Self {
119        self.trailing = Some(text.into());
120        self
121    }
122
123    /// Highlights the row as selected.
124    pub fn selected(mut self, selected: bool) -> Self {
125        self.selected = selected;
126        self
127    }
128
129    /// Runs `handler` when the row is clicked; also makes it hoverable.
130    pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
131        self.on_click = Some(Rc::new(handler));
132        self
133    }
134}
135
136impl View for ListItem {
137    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
138        let theme = *cx.theme();
139        let hovered = cx.is_hovered() && self.on_click.is_some();
140        let mut children = Vec::new();
141        match &self.leading {
142            Leading::None => {}
143            Leading::Icon(icon) => {
144                let i = util::icon(cx, *icon, 18.0, theme.color(Semantic::InkMuted));
145                children.push(cx.container(
146                    LayoutStyle {
147                        width: Some(36.0),
148                        height: Some(36.0),
149                        align: Align::Center,
150                        justify: Justify::Center,
151                        ..LayoutStyle::stack(Axis::Horizontal)
152                    },
153                    &[i],
154                    Paint::Quad(QuadPaint {
155                        corner_radius: 10.0,
156                        ..QuadPaint::new(theme.color(Semantic::Control))
157                    }),
158                ));
159            }
160            Leading::Avatar(name) => children.push(cx.child(&Avatar::new(name.clone()))),
161        }
162        let mut text = vec![util::label(
163            cx,
164            &self.title,
165            TextStyle::Body,
166            Some(Weight::SEMIBOLD),
167            theme.color(Semantic::Ink),
168        )];
169        if let Some(s) = &self.subtitle {
170            text.push(util::label(cx, s, TextStyle::Callout, None, theme.color(Semantic::InkMuted)));
171        }
172        children.push(cx.container(
173            LayoutStyle {
174                grow: 1.0,
175                min_width: Some(0.0),
176                spacing: 2.0,
177                ..LayoutStyle::stack(Axis::Vertical)
178            },
179            &text,
180            Paint::None,
181        ));
182        if let Some(t) = &self.trailing {
183            // It keeps its width: the text beside it is what wraps in a narrow list.
184            let attrs = util::attrs(&theme, TextStyle::Callout, None);
185            let width = cx.measure_text(t, &attrs).width.ceil();
186            let style = LayoutStyle { min_width: Some(width), ..LayoutStyle::leaf() };
187            children.push(cx.text(t, &attrs, theme.color(Semantic::InkMuted), style));
188        }
189        if self.on_click.is_some() {
190            children.push(util::icon(cx, IconName::ChevronRight, 16.0, theme.color(Semantic::InkMuted)));
191        }
192        let fill = if self.selected {
193            theme.color(Semantic::Control)
194        } else if hovered {
195            theme.color(Semantic::Ink).with_alpha(0.04)
196        } else {
197            Color::TRANSPARENT
198        };
199        let style = LayoutStyle {
200            padding: Insets::symmetric(10.0, 12.0),
201            spacing: 12.0,
202            align: Align::Center,
203            ..LayoutStyle::stack(Axis::Horizontal)
204        };
205        let node = cx.container(
206            style,
207            &children,
208            Paint::Quad(QuadPaint { corner_radius: 10.0, ..QuadPaint::new(fill) }),
209        );
210        cx.semantics(
211            node,
212            Semantics::new(if self.on_click.is_some() { Role::Button } else { Role::ListItem }),
213        );
214        if let Some(h) = &self.on_click {
215            cx.on_click(node, h.clone());
216            cx.focusable(node, 10.0);
217            cx.cursor(node, CursorIcon::Pointer);
218        }
219        node
220    }
221}
222
223/// A table column: title, relative width and alignment.
224#[derive(Clone, Debug)]
225#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
226pub struct Column {
227    title: String,
228    weight: f32,
229    align_end: bool,
230}
231
232#[cfg_attr(feature = "live", ravenrs_live::expose)]
233impl Column {
234    /// A column with this header title, weight 1.0, left-aligned.
235    pub fn new(title: impl Into<String>) -> Self {
236        Self { title: title.into(), weight: 1.0, align_end: false }
237    }
238
239    /// Relative width (default 1.0).
240    pub fn weight(mut self, weight: f32) -> Self {
241        self.weight = weight.max(0.1);
242        self
243    }
244
245    /// Right-aligns the column, for numbers.
246    pub fn numeric(mut self) -> Self {
247        self.align_end = true;
248        self
249    }
250}
251
252/// A simple data table with a header, hover-highlighted rows and numeric columns.
253#[derive(Clone, Debug)]
254#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
255pub struct Table {
256    columns: Vec<Column>,
257    rows: Vec<Vec<String>>,
258}
259
260#[cfg_attr(feature = "live", ravenrs_live::expose)]
261impl Table {
262    /// An empty table with these columns.
263    pub fn new(columns: impl IntoIterator<Item = Column>) -> Self {
264        Self { columns: columns.into_iter().collect(), rows: Vec::new() }
265    }
266
267    /// Appends a row; cells map to columns in order.
268    pub fn row(mut self, cells: impl IntoIterator<Item = impl Into<String>>) -> Self {
269        self.rows.push(cells.into_iter().map(Into::into).collect());
270        self
271    }
272}
273
274struct TableRow<'a> {
275    columns: &'a [Column],
276    cells: &'a [String],
277    header: bool,
278}
279
280impl View for TableRow<'_> {
281    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
282        let theme = *cx.theme();
283        let cells: Vec<NodeId> = self
284            .columns
285            .iter()
286            .enumerate()
287            .map(|(i, col)| {
288                let text = if self.header {
289                    col.title.to_uppercase()
290                } else {
291                    self.cells.get(i).cloned().unwrap_or_default()
292                };
293                let (style, color, weight) = if self.header {
294                    (TextStyle::Caption, theme.color(Semantic::InkMuted), None)
295                } else if i == 0 {
296                    (TextStyle::Body, theme.color(Semantic::Ink), Some(Weight::SEMIBOLD))
297                } else {
298                    (TextStyle::Body, theme.color(Semantic::InkMuted), None)
299                };
300                let label = util::label(cx, &text, style, weight, color);
301                let header = self.header;
302                let cell = cx.container(
303                    LayoutStyle {
304                        grow: col.weight,
305                        width: Some(0.0),
306                        min_width: Some(0.0),
307                        justify: if col.align_end { Justify::End } else { Justify::Start },
308                        ..LayoutStyle::stack(Axis::Horizontal)
309                    },
310                    &[label],
311                    Paint::None,
312                );
313                let semantics = if header {
314                    Semantics::new(Role::ColumnHeader).name(col.title.clone())
315                } else {
316                    Semantics::new(Role::Cell)
317                };
318                cx.semantics(cell, semantics);
319                cell
320            })
321            .collect();
322        let hovered = !self.header && cx.is_hovered();
323        let fill = if hovered { theme.color(Semantic::Ink).with_alpha(0.04) } else { Color::TRANSPARENT };
324        let style = LayoutStyle {
325            padding: Insets::symmetric(if self.header { 10.0 } else { 12.0 }, 16.0),
326            spacing: 16.0,
327            align: Align::Center,
328            ..LayoutStyle::stack(Axis::Horizontal)
329        };
330        let node = cx.container(style, &cells, Paint::Quad(QuadPaint::new(fill)));
331        cx.semantics(node, Semantics::new(Role::Row));
332        if !self.header {
333            cx.cursor(node, CursorIcon::Default);
334        }
335        node
336    }
337}
338
339impl View for Table {
340    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
341        let theme = *cx.theme();
342        let mut children = vec![cx.child(&TableRow { columns: &self.columns, cells: &[], header: true })];
343        for (i, row) in self.rows.iter().enumerate() {
344            let line = cx.child(&crate::Divider::horizontal());
345            cx.semantics(line, Semantics::hidden());
346            children.push(line);
347            children.push(
348                cx.keyed_child(i as u64, &TableRow { columns: &self.columns, cells: row, header: false }),
349            );
350        }
351        let style = LayoutStyle {
352            align: Align::Stretch,
353            align_self: Some(Align::Stretch),
354            ..LayoutStyle::stack(Axis::Vertical)
355        };
356        let node = cx.container(
357            style,
358            &children,
359            Paint::Quad(QuadPaint {
360                corner_radius: theme.radii.lg,
361                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
362                ..QuadPaint::new(theme.color(Semantic::Surface))
363            }),
364        );
365        cx.semantics(node, Semantics::new(Role::Table));
366        cx.clip(node);
367        node
368    }
369}