Skip to main content

ravenrs_widgets/grid/
layout.rs

1//! How a grid is arranged, apart from its data: what a saved scenario holds.
2//!
3//! Columns go by title and not by position, so a scenario survives a column being
4//! added or a window too narrow for some of them.
5
6use serde::{Deserialize, Serialize};
7
8use super::model::{GridColumn, SortDirection, Summary};
9use super::query::GridQuery;
10
11/// One column's place in a [`GridLayout`].
12#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
13#[serde(default)]
14#[non_exhaustive]
15#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
16pub struct ColumnLayout {
17    /// Its title.
18    pub title: String,
19    /// Its width in logical pixels.
20    pub width: f32,
21    /// It is out of sight.
22    pub hidden: bool,
23    /// It stays in place at the left while the others scroll.
24    pub pinned: bool,
25}
26
27/// One sorted column of a [`GridLayout`].
28#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(default)]
30#[non_exhaustive]
31#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
32pub struct SortLayout {
33    /// Its title.
34    pub title: String,
35    /// Largest first.
36    pub descending: bool,
37}
38
39/// One column's filter in a [`GridLayout`].
40#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(default)]
42#[non_exhaustive]
43#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
44pub struct FilterLayout {
45    /// Its title.
46    pub title: String,
47    /// What the filter row holds for it.
48    pub text: String,
49}
50
51/// One column's summary in a [`GridLayout`].
52#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(default)]
54#[non_exhaustive]
55#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
56pub struct SummaryLayout {
57    /// Its title.
58    pub title: String,
59    /// `count`, `sum`, `average`, `min` or `max`; empty for none.
60    pub kind: String,
61    /// The formula that takes the place of the kind.
62    pub formula: Option<String>,
63}
64
65fn summary_name(summary: Summary) -> &'static str {
66    match summary {
67        Summary::Count => "count",
68        Summary::Average => "average",
69        Summary::Min => "min",
70        Summary::Max => "max",
71        _ => "sum",
72    }
73}
74
75fn summary_from(name: &str) -> Option<Summary> {
76    match name {
77        "count" => Some(Summary::Count),
78        "sum" => Some(Summary::Sum),
79        "average" => Some(Summary::Average),
80        "min" => Some(Summary::Min),
81        "max" => Some(Summary::Max),
82        _ => None,
83    }
84}
85
86/// Everything about how a grid is arranged: its columns in order, sorting, groups,
87/// filters and search. Turn it into JSON to keep it.
88///
89/// ```
90/// use ravenrs_widgets::GridLayout;
91///
92/// let kept = GridLayout::default().to_json();
93/// assert_eq!(GridLayout::from_json(&kept), Ok(GridLayout::default()));
94/// ```
95#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
96#[serde(default)]
97#[non_exhaustive]
98#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
99pub struct GridLayout {
100    /// The columns, in the order they are shown.
101    pub columns: Vec<ColumnLayout>,
102    /// Sorted columns, in priority order.
103    pub sort: Vec<SortLayout>,
104    /// Grouped columns by title, outermost first.
105    pub group_by: Vec<String>,
106    /// Filters by column.
107    pub filters: Vec<FilterLayout>,
108    /// What is being searched for.
109    pub search: String,
110    /// The space to drop columns on and group by them is showing.
111    pub group_panel: bool,
112    /// Rows on a page, when it is in pages.
113    pub page_size: Option<usize>,
114    /// What each column sums up in group rows and in the footer.
115    pub summaries: Vec<SummaryLayout>,
116}
117
118impl Default for GridLayout {
119    fn default() -> Self {
120        Self {
121            columns: Vec::new(),
122            sort: Vec::new(),
123            group_by: Vec::new(),
124            filters: Vec::new(),
125            search: String::new(),
126            group_panel: true,
127            page_size: None,
128            summaries: Vec::new(),
129        }
130    }
131}
132
133#[cfg_attr(feature = "live", ravenrs_live::expose)]
134impl GridLayout {
135    /// The layout as JSON, to keep in a file or a database.
136    pub fn to_json(&self) -> String {
137        serde_json::to_string(self).unwrap_or_default()
138    }
139
140    /// Reads what [`to_json`](Self::to_json) wrote. What an older version did not
141    /// know takes its default.
142    pub fn from_json(text: &str) -> Result<Self, String> {
143        serde_json::from_str(text).map_err(|e| e.to_string())
144    }
145}
146
147/// A named [`GridLayout`], for someone to come back to with one click.
148#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
149#[non_exhaustive]
150#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
151pub struct GridScenario {
152    /// The name it was saved under.
153    pub name: String,
154    /// How the grid was arranged.
155    pub layout: GridLayout,
156}
157
158#[cfg_attr(feature = "live", ravenrs_live::expose)]
159impl GridScenario {
160    /// A scenario under this name.
161    pub fn new(name: impl Into<String>, layout: GridLayout) -> Self {
162        Self { name: name.into(), layout }
163    }
164
165    /// A list of scenarios as JSON, to keep in a file or a database.
166    pub fn list_to_json(scenarios: &[Self]) -> String {
167        serde_json::to_string(scenarios).unwrap_or_default()
168    }
169
170    /// Reads what [`list_to_json`](Self::list_to_json) wrote.
171    pub fn list_from_json(text: &str) -> Result<Vec<Self>, String> {
172        serde_json::from_str(text).map_err(|e| e.to_string())
173    }
174}
175
176/// What the grid keeps about its columns beyond [`GridQuery`].
177#[derive(Clone, Debug, PartialEq)]
178pub(crate) struct Arrangement {
179    /// Column indices in the order they are shown.
180    pub order: Vec<usize>,
181    /// Per column, whether it stays in place at the left.
182    pub pinned: Vec<bool>,
183    pub group_panel: bool,
184    pub page_size: Option<usize>,
185    /// Per column, its summary.
186    pub summaries: Vec<Option<Summary>>,
187    /// Per column, the formula that takes the place of its summary.
188    pub formulas: Vec<Option<String>>,
189}
190
191#[cfg_attr(feature = "live", ravenrs_live::expose)]
192impl Arrangement {
193    pub fn new(columns: &[GridColumn], group_panel: bool, page_size: Option<usize>) -> Self {
194        Self {
195            order: (0..columns.len()).collect(),
196            pinned: columns.iter().map(|c| c.pinned).collect(),
197            group_panel,
198            page_size,
199            summaries: columns.iter().map(|c| c.summary).collect(),
200            formulas: columns.iter().map(|c| c.formula.clone()).collect(),
201        }
202    }
203
204    /// Puts `column` before `before`, or last.
205    pub fn move_column(&mut self, column: usize, before: Option<usize>) {
206        self.order.retain(|c| *c != column);
207        let at = before.and_then(|b| self.order.iter().position(|c| *c == b)).unwrap_or(self.order.len());
208        self.order.insert(at, column);
209    }
210
211    /// The columns to show, the pinned ones first. A grouped column shows in its groups.
212    pub fn visible(&self, query: &GridQuery) -> Vec<usize> {
213        let shown = |c: &usize| !query.is_hidden(*c) && !query.group_by.contains(c);
214        let pinned = |c: &usize| self.pinned.get(*c).copied().unwrap_or(false);
215        let mut out: Vec<usize> = self.order.iter().filter(|c| shown(c) && pinned(c)).copied().collect();
216        out.extend(self.order.iter().filter(|c| shown(c) && !pinned(c)));
217        out
218    }
219}
220
221/// The layout of a grid as it stands.
222pub(crate) fn capture(
223    columns: &[GridColumn],
224    query: &GridQuery,
225    widths: &[f32],
226    arrangement: &Arrangement,
227) -> GridLayout {
228    let title = |c: usize| columns.get(c).map_or(String::new(), |col| col.title.clone());
229    GridLayout {
230        columns: arrangement
231            .order
232            .iter()
233            .map(|&c| ColumnLayout {
234                title: title(c),
235                width: widths.get(c).copied().unwrap_or_default(),
236                hidden: query.is_hidden(c),
237                pinned: arrangement.pinned.get(c).copied().unwrap_or(false),
238            })
239            .collect(),
240        sort: query
241            .sort
242            .iter()
243            .map(|(c, d)| SortLayout { title: title(*c), descending: *d == SortDirection::Descending })
244            .collect(),
245        group_by: query.group_by.iter().map(|c| title(*c)).collect(),
246        filters: (0..columns.len())
247            .filter(|c| !query.filter(*c).trim().is_empty())
248            .map(|c| FilterLayout { title: title(c), text: query.filter(c).to_owned() })
249            .collect(),
250        search: query.search.clone(),
251        group_panel: arrangement.group_panel,
252        page_size: arrangement.page_size,
253        summaries: (0..columns.len())
254            .map(|c| SummaryLayout {
255                title: title(c),
256                kind: arrangement.summaries[c].map_or_else(String::new, |s| summary_name(s).to_owned()),
257                formula: arrangement.formulas[c].clone(),
258            })
259            .collect(),
260    }
261}
262
263/// Arranges a grid as `layout` says. A column it does not name stays where it is, at
264/// the end, and a name it has that the grid does not is ignored.
265pub(crate) fn apply(
266    layout: &GridLayout,
267    columns: &[GridColumn],
268    query: &mut GridQuery,
269    widths: &mut [f32],
270    arrangement: &mut Arrangement,
271) {
272    let index = |title: &str| columns.iter().position(|c| c.title == title);
273    let named: Vec<(usize, &ColumnLayout)> =
274        layout.columns.iter().filter_map(|c| Some((index(&c.title)?, c))).collect();
275    if !named.is_empty() {
276        let mut order: Vec<usize> = named.iter().map(|(c, _)| *c).collect();
277        order.extend((0..columns.len()).filter(|c| !named.iter().any(|(n, _)| n == c)));
278        arrangement.order = order;
279        for (c, column) in named {
280            if column.width > 0.0 {
281                widths[c] = column.width.max(columns[c].min_width);
282            }
283            if query.hidden.len() <= c {
284                query.hidden.resize(c + 1, false);
285            }
286            query.hidden[c] = column.hidden;
287            arrangement.pinned[c] = column.pinned;
288        }
289    }
290    query.sort = layout
291        .sort
292        .iter()
293        .filter_map(|s| {
294            let direction = if s.descending { SortDirection::Descending } else { SortDirection::Ascending };
295            Some((index(&s.title)?, direction))
296        })
297        .collect();
298    query.group_by = layout.group_by.iter().filter_map(|t| index(t)).collect();
299    query.collapsed.clear();
300    query.filters.clear();
301    for filter in &layout.filters {
302        if let Some(c) = index(&filter.title) {
303            query.set_filter(c, filter.text.clone());
304        }
305    }
306    query.search.clone_from(&layout.search);
307    arrangement.group_panel = layout.group_panel;
308    arrangement.page_size = layout.page_size;
309    for summary in &layout.summaries {
310        if let Some(c) = index(&summary.title) {
311            arrangement.summaries[c] = summary_from(&summary.kind);
312            arrangement.formulas[c].clone_from(&summary.formula);
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    fn columns() -> Vec<GridColumn> {
322        vec![GridColumn::text("A"), GridColumn::text("B"), GridColumn::number("C")]
323    }
324
325    #[test]
326    fn a_layout_comes_back_as_it_was() {
327        let columns = columns();
328        let mut arrangement = Arrangement::new(&columns, true, Some(50));
329        arrangement.move_column(2, Some(0));
330        arrangement.pinned[1] = true;
331        let mut query = GridQuery { search: "x".into(), group_by: vec![1], ..GridQuery::default() };
332        query.sort.push((2, SortDirection::Descending));
333        query.set_filter(0, ">1".into());
334        let widths = vec![100.0, 200.0, 300.0];
335        let saved = capture(&columns, &query, &widths, &arrangement);
336        let read = GridLayout::from_json(&saved.to_json()).expect("json");
337        let (mut q, mut w, mut a) =
338            (GridQuery::default(), vec![1.0, 1.0, 1.0], Arrangement::new(&columns, false, None));
339        apply(&read, &columns, &mut q, &mut w, &mut a);
340        assert_eq!(a.order, [2, 0, 1]);
341        assert_eq!(a.pinned, [false, true, false]);
342        assert_eq!(a.page_size, Some(50));
343        assert!(a.group_panel);
344        assert_eq!(w, [100.0, 200.0, 300.0]);
345        assert_eq!(q.group_by, [1]);
346        assert_eq!(q.sort, [(2, SortDirection::Descending)]);
347        assert_eq!(q.filter(0), ">1");
348        assert_eq!(q.search, "x");
349    }
350
351    #[test]
352    fn a_scenario_survives_columns_that_changed() {
353        let layout = GridLayout {
354            columns: vec![
355                ColumnLayout { title: "C".into(), width: 90.0, ..ColumnLayout::default() },
356                ColumnLayout { title: "Gone".into(), width: 90.0, ..ColumnLayout::default() },
357            ],
358            group_by: vec!["Gone".into(), "B".into()],
359            ..GridLayout::default()
360        };
361        let columns = columns();
362        let (mut q, mut w, mut a) =
363            (GridQuery::default(), vec![1.0, 1.0, 1.0], Arrangement::new(&columns, true, None));
364        apply(&layout, &columns, &mut q, &mut w, &mut a);
365        assert_eq!(a.order, [2, 0, 1], "what it does not name goes last");
366        assert_eq!(q.group_by, [1]);
367    }
368
369    #[test]
370    fn what_an_older_version_did_not_know_takes_its_default() {
371        let layout = GridLayout::from_json(r#"{"search":"x"}"#).expect("json");
372        assert!(layout.group_panel);
373        assert_eq!(layout.search, "x");
374    }
375
376    #[test]
377    fn pinned_columns_come_first_and_grouped_ones_leave() {
378        let columns = columns();
379        let mut arrangement = Arrangement::new(&columns, true, None);
380        arrangement.pinned[2] = true;
381        let query = GridQuery { group_by: vec![1], ..GridQuery::default() };
382        assert_eq!(arrangement.visible(&query), [2, 0]);
383    }
384
385    #[test]
386    fn formulas_go_with_the_layout() {
387        let columns = columns();
388        let mut arrangement = Arrangement::new(&columns, true, None);
389        arrangement.formulas[2] = Some("Sum([C]) / Count()".into());
390        arrangement.summaries[1] = Some(Summary::Count);
391        let saved = capture(&columns, &GridQuery::default(), &[1.0, 1.0, 1.0], &arrangement);
392        let read = GridLayout::from_json(&saved.to_json()).expect("json");
393        let (mut q, mut w, mut a) =
394            (GridQuery::default(), vec![1.0, 1.0, 1.0], Arrangement::new(&columns, true, None));
395        apply(&read, &columns, &mut q, &mut w, &mut a);
396        assert_eq!(a.formulas[2].as_deref(), Some("Sum([C]) / Count()"));
397        assert_eq!(a.summaries[1], Some(Summary::Count));
398    }
399
400    #[test]
401    fn scenarios_go_to_json_and_back() {
402        let list = vec![GridScenario::new("Por conta", GridLayout::default())];
403        assert_eq!(GridScenario::list_from_json(&GridScenario::list_to_json(&list)), Ok(list));
404    }
405}